diff --git a/nym-vpn-core/Cargo.lock b/nym-vpn-core/Cargo.lock index 766dd4cc35..19cb0b812d 100644 --- a/nym-vpn-core/Cargo.lock +++ b/nym-vpn-core/Cargo.lock @@ -4573,10 +4573,12 @@ name = "nym-vpn-network-config" version = "1.0.0-dev" dependencies = [ "anyhow", + "itertools 0.13.0", "nym-config", "reqwest 0.11.27", "serde", "serde_json", + "tempfile", "tokio", "tokio-util", "tracing", diff --git a/nym-vpn-core/crates/nym-vpn-network-config/Cargo.toml b/nym-vpn-core/crates/nym-vpn-network-config/Cargo.toml index 636a241ee7..8e496eb853 100644 --- a/nym-vpn-core/crates/nym-vpn-network-config/Cargo.toml +++ b/nym-vpn-core/crates/nym-vpn-network-config/Cargo.toml @@ -10,6 +10,7 @@ license.workspace = true [dependencies] anyhow.workspace = true +itertools.workspace = true reqwest = { workspace = true, default-features = false, features = [ "blocking", "rustls-tls", @@ -18,7 +19,11 @@ reqwest = { workspace = true, default-features = false, features = [ nym-config.workspace = true serde.workspace = true serde_json.workspace = true +tempfile.workspace = true tokio = { workspace = true, features = ["time", "macros"] } tokio-util.workspace = true tracing.workspace = true url = { workspace = true, features = ["serde"] } + +[build-dependencies] +serde_json.workspace = true diff --git a/nym-vpn-core/crates/nym-vpn-network-config/build.rs b/nym-vpn-core/crates/nym-vpn-network-config/build.rs new file mode 100644 index 0000000000..772d51dda4 --- /dev/null +++ b/nym-vpn-core/crates/nym-vpn-network-config/build.rs @@ -0,0 +1,87 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use std::path::Path; + +const DEFAULT_DIR: &str = "default"; +const MAINNET_DISCOVERY_JSON: &str = "mainnet_discovery.json"; +const DEFAULT_ENVS_JSON: &str = "envs.json"; + +fn default_envs() { + let json_path = Path::new(DEFAULT_DIR).join(DEFAULT_ENVS_JSON); + + let json_str = std::fs::read_to_string(json_path).expect("Failed to read JSON file"); + let networks: Vec = serde_json::from_str(&json_str).expect("Failed to parse JSON file"); + + let networks_literal = networks + .iter() + .map(|s| format!("\"{}\"", s)) + .collect::>() + .join(", "); + + let generated_code = format!( + r#" + impl Default for RegisteredNetworks {{ + fn default() -> Self {{ + RegisteredNetworks {{ + inner: [ {networks_literal} ] + .iter() + .cloned() + .map(String::from) + .collect::>(), + }} + }} + }} + "#, + networks_literal = networks_literal, + ); + + let out_dir = std::env::var("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("default_envs.rs"); + std::fs::write(&dest_path, generated_code).expect("Failed to write generated code"); +} + +fn default_mainnet_discovery() { + let json_path = Path::new(DEFAULT_DIR).join(MAINNET_DISCOVERY_JSON); + println!("cargo:rerun-if-changed={}", json_path.display()); + + let json_str = std::fs::read_to_string(json_path).expect("Failed to read JSON file"); + let json_value: serde_json::Value = + serde_json::from_str(&json_str).expect("Failed to parse JSON file"); + + let network_name = json_value["network_name"] + .as_str() + .expect("Failed to parse network name"); + let nym_api_url = json_value["nym_api_url"] + .as_str() + .expect("Failed to parse nym_api_url"); + let nym_vpn_api_url = json_value["nym_vpn_api_url"] + .as_str() + .expect("Failed to parse nym_vpn_api_url"); + + let generated_code = format!( + r#" + impl Default for Discovery {{ + fn default() -> Self {{ + Self {{ + network_name: "{}".to_string(), + nym_api_url: "{}".parse().expect("Failed to parse NYM API URL"), + nym_vpn_api_url: "{}".parse().expect("Failed to parse NYM VPN API URL"), + }} + }} + }} + "#, + network_name, nym_api_url, nym_vpn_api_url + ); + + let out_dir = std::env::var("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("default_discovery.rs"); + std::fs::write(&dest_path, generated_code).expect("Failed to write generated code"); +} + +fn main() { + default_envs(); + default_mainnet_discovery(); + + println!("cargo:rerun-if-changed={DEFAULT_DIR}"); +} diff --git a/nym-vpn-core/crates/nym-vpn-network-config/default/envs.json b/nym-vpn-core/crates/nym-vpn-network-config/default/envs.json new file mode 100644 index 0000000000..a0d050bee8 --- /dev/null +++ b/nym-vpn-core/crates/nym-vpn-network-config/default/envs.json @@ -0,0 +1,6 @@ +[ + "mainnet", + "sandbox", + "canary", + "qa" +] diff --git a/nym-vpn-core/env/discovery.json b/nym-vpn-core/crates/nym-vpn-network-config/default/mainnet_discovery.json similarity index 100% rename from nym-vpn-core/env/discovery.json rename to nym-vpn-core/crates/nym-vpn-network-config/default/mainnet_discovery.json diff --git a/nym-vpn-core/crates/nym-vpn-network-config/src/bootstrap.rs b/nym-vpn-core/crates/nym-vpn-network-config/src/bootstrap.rs index 1f86689ad6..d3b5f4de1a 100644 --- a/nym-vpn-core/crates/nym-vpn-network-config/src/bootstrap.rs +++ b/nym-vpn-core/crates/nym-vpn-network-config/src/bootstrap.rs @@ -14,13 +14,16 @@ use super::{nym_network::NymNetwork, MAX_FILE_AGE, NETWORKS_SUBDIR}; const DISCOVERY_FILE: &str = "discovery.json"; const DISCOVERY_WELLKNOWN: &str = "https://nymvpn.com/api/public/v1/.wellknown"; -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct Discovery { pub(super) network_name: String, pub(super) nym_api_url: Url, pub(super) nym_vpn_api_url: Url, } +// Include the generated Default implementation +include!(concat!(env!("OUT_DIR"), "/default_discovery.rs")); + impl Discovery { fn path(config_dir: &Path, network_name: &str) -> PathBuf { config_dir @@ -48,11 +51,23 @@ impl Discovery { pub fn fetch(network_name: &str) -> anyhow::Result { let discovery: DiscoveryResponse = { let url = Self::endpoint(network_name)?; + tracing::info!("Fetching nym network discovery from: {}", url); - reqwest::blocking::get(url.clone()) + let response = reqwest::blocking::get(url.clone()) + .inspect_err(|err| tracing::warn!("{}", err)) .with_context(|| format!("Failed to fetch discovery from {}", url))? - .json() - .with_context(|| "Failed to parse discovery") + .error_for_status() + .inspect_err(|err| tracing::warn!("{}", err)) + .with_context(|| "Discovery endpoint returned error response".to_owned())?; + + let text_response = response + .text() + .inspect_err(|err| tracing::warn!("{}", err)) + .with_context(|| "Failed to read response text")?; + tracing::debug!("Discovery response: {:#?}", text_response); + + serde_json::from_str(&text_response) + .with_context(|| "Failed to parse discovery response") }?; if discovery.network_name != network_name { anyhow::bail!("Network name mismatch between requested and fetched discovery") @@ -92,13 +107,32 @@ impl Discovery { Ok(()) } + fn try_update_file(config_dir: &Path, network_name: &str) -> anyhow::Result<()> { + if Self::path_is_stale(config_dir, network_name)? { + Self::fetch(network_name)?.write_to_file(config_dir)?; + } + Ok(()) + } + pub(super) fn ensure_exists(config_dir: &Path, network_name: &str) -> anyhow::Result { + if !Self::path(config_dir, network_name).exists() && network_name == "mainnet" { + tracing::info!("No discovery file found, writing default discovery file"); + Self::default() + .write_to_file(config_dir) + .inspect_err(|err| tracing::warn!("Failed to write default discovery file: {err}")) + .ok(); + } + // Download the file if it doesn't exists, or if the file is too old, refresh it. // TODO: in the future, we should only refresh the discovery file when the tunnel is up. // Probably in a background task. - if Self::path_is_stale(config_dir, network_name)? { - Self::fetch(network_name)?.write_to_file(config_dir)?; - } + + Self::try_update_file(config_dir, network_name) + .inspect_err(|err| { + tracing::warn!("Failed to refresh discovery file: {err}"); + tracing::warn!("Attempting to use existing discovery file"); + }) + .ok(); Self::read_from_file(config_dir, network_name) } @@ -119,27 +153,6 @@ impl Discovery { } } -impl Default for Discovery { - fn default() -> Self { - let default_network_details = NymNetworkDetails::default(); - Self { - network_name: default_network_details.network_name, - nym_api_url: default_network_details - .endpoints - .first() - .and_then(|e| e.api_url().clone()) - .expect("default network details not setup correctly"), - nym_vpn_api_url: default_network_details - .nym_vpn_api_url - .map(|url| { - url.parse() - .expect("default network details not setup correctly") - }) - .expect("default network details not setup correctly"), - } - } -} - // The response type we fetch from the discovery endpoint #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] struct DiscoveryResponse { @@ -166,3 +179,34 @@ impl TryFrom for Discovery { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_discovery_endpoint() { + let network_name = "mainnet"; + let url = Discovery::endpoint(network_name).unwrap(); + assert_eq!( + url, + "https://nymvpn.com/api/public/v1/.wellknown/mainnet/discovery.json" + .parse() + .unwrap() + ); + } + + #[test] + fn test_discovery_fetch() { + let network_name = "mainnet"; + let discovery = Discovery::fetch(network_name).unwrap(); + assert_eq!(discovery.network_name, network_name); + } + + #[test] + fn test_discovery_default_same_as_fetched() { + let default_discovery = Discovery::default(); + let fetched_discovery = Discovery::fetch(&default_discovery.network_name).unwrap(); + assert_eq!(default_discovery, fetched_discovery); + } +} diff --git a/nym-vpn-core/crates/nym-vpn-network-config/src/envs.rs b/nym-vpn-core/crates/nym-vpn-network-config/src/envs.rs new file mode 100644 index 0000000000..52c55bd9d4 --- /dev/null +++ b/nym-vpn-core/crates/nym-vpn-network-config/src/envs.rs @@ -0,0 +1,209 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use std::{ + collections::HashSet, + fmt, + path::{Path, PathBuf}, +}; + +use anyhow::Context; +use itertools::Itertools; + +use super::{MAX_FILE_AGE, NETWORKS_SUBDIR}; + +// TODO: integrate with nym-vpn-api-client + +const ENVS_FILE: &str = "envs.json"; +const ENVS_WELLKNOWN: &str = "https://nymvpn.com/api/public/v1/.wellknown/envs.json"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegisteredNetworks { + inner: HashSet, +} + +// Include the generated Default implementation +include!(concat!(env!("OUT_DIR"), "/default_envs.rs")); + +impl<'de> serde::de::Deserialize<'de> for RegisteredNetworks { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + let registered: HashSet = serde::de::Deserialize::deserialize(deserializer)?; + Ok(RegisteredNetworks { inner: registered }) + } +} + +impl serde::ser::Serialize for RegisteredNetworks { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + self.inner.serialize(serializer) + } +} + +impl fmt::Display for RegisteredNetworks { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.inner.iter().format(", ")) + } +} + +impl RegisteredNetworks { + fn path(config_dir: &Path) -> PathBuf { + config_dir.join(NETWORKS_SUBDIR).join(ENVS_FILE) + } + + fn path_is_stale(config_dir: &Path) -> anyhow::Result { + if let Some(age) = crate::util::get_age_of_file(&Self::path(config_dir))? { + Ok(age > MAX_FILE_AGE) + } else { + Ok(true) + } + } + + fn endpoint() -> anyhow::Result { + ENVS_WELLKNOWN.parse().map_err(Into::into) + } + + fn fetch() -> anyhow::Result { + let url = Self::endpoint()?; + tracing::info!("Fetching registered networks from: {}", url); + + let response = reqwest::blocking::get(url.clone()) + .inspect_err(|err| tracing::warn!("{}", err)) + .with_context(|| format!("Failed to fetch envs from {}", url))? + .error_for_status() + .inspect_err(|err| tracing::warn!("{}", err)) + .with_context(|| "Envs endpoint returned error response".to_owned())?; + + let text_response = response + .text() + .inspect_err(|err| tracing::warn!("{}", err)) + .with_context(|| "Failed to read response text")?; + tracing::debug!("Envs response: {:#?}", text_response); + + serde_json::from_str(&text_response).with_context(|| "Failed to parse envs response") + } + + fn read_from_file(config_dir: &Path) -> anyhow::Result { + let path = Self::path(config_dir); + tracing::info!( + "Reading registered networks from file: {:?}", + path.display() + ); + + let file_str = std::fs::read_to_string(&path)?; + let registered_networks: RegisteredNetworks = serde_json::from_str(&file_str)?; + Ok(registered_networks) + } + + fn write_to_file(&self, config_dir: &Path) -> anyhow::Result<()> { + let path = Self::path(config_dir); + tracing::info!("Writing registered networks to file: {:?}", path.display()); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent directories for {:?}", path))?; + } + + let file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&path) + .with_context(|| format!("Failed to open envs file: {:?}", path))?; + + serde_json::to_writer_pretty(&file, &self) + .with_context(|| format!("Failed to write envs file: {:?}", path))?; + + Ok(()) + } + + fn try_update_file(config_dir: &Path) -> anyhow::Result<()> { + if Self::path_is_stale(config_dir)? { + Self::fetch()?.write_to_file(config_dir)?; + } + + Ok(()) + } + + pub(super) fn ensure_exists(config_dir: &Path) -> anyhow::Result { + if !Self::path(config_dir).exists() { + Self::default() + .write_to_file(config_dir) + .inspect_err(|err| tracing::warn!("Failed to write default envs file: {err}")) + .ok(); + } + + // Download the file if it doesn't exists, or if the file is too old, refresh it. + // TODO: in the future, we should only refresh the discovery file when the tunnel is up. + // Probably in a background task. + + Self::try_update_file(config_dir) + .inspect_err(|err| { + tracing::warn!("Failed to update envs file: {err}"); + tracing::warn!("Attempting to read envs file instead"); + }) + .ok(); + + Self::read_from_file(config_dir) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_registered_networks_endpoint() { + let endpoint = RegisteredNetworks::endpoint().unwrap(); + assert_eq!(endpoint, ENVS_WELLKNOWN); + } + + #[test] + fn test_registered_networks_serialization() { + let registered_networks = RegisteredNetworks { + inner: vec!["mainnet".to_string(), "testnet".to_string()] + .into_iter() + .collect(), + }; + + let serialized = serde_json::to_string(®istered_networks).unwrap(); + let deserialized: RegisteredNetworks = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(registered_networks, deserialized); + } + + #[test] + fn test_registered_networks_default() { + let registered_networks = RegisteredNetworks::default(); + assert!(registered_networks.inner.contains("mainnet")); + } + + #[test] + fn test_registered_networks_fetch() { + let registered_networks = RegisteredNetworks::fetch().unwrap(); + assert!(registered_networks.inner.contains("mainnet")); + } + + #[test] + fn test_registered_networks_write_to_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_dir = temp_dir.path(); + + let registered_networks = RegisteredNetworks::default(); + registered_networks.write_to_file(config_dir).unwrap(); + + let read_registered_networks = RegisteredNetworks::read_from_file(config_dir).unwrap(); + assert_eq!(registered_networks, read_registered_networks); + } + + #[test] + fn test_envs_default_same_as_fetched() { + let default_envs = RegisteredNetworks::default(); + let fetched_envs = RegisteredNetworks::fetch().unwrap(); + assert_eq!(default_envs, fetched_envs); + } +} diff --git a/nym-vpn-core/crates/nym-vpn-network-config/src/lib.rs b/nym-vpn-core/crates/nym-vpn-network-config/src/lib.rs index 4ba86c0c2c..3e64354bcf 100644 --- a/nym-vpn-core/crates/nym-vpn-network-config/src/lib.rs +++ b/nym-vpn-core/crates/nym-vpn-network-config/src/lib.rs @@ -2,11 +2,13 @@ // SPDX-License-Identifier: GPL-3.0-only mod bootstrap; +mod envs; mod nym_network; mod nym_vpn_network; mod refresh; mod util; +use envs::RegisteredNetworks; pub use nym_network::NymNetwork; pub use nym_vpn_network::NymVpnNetwork; @@ -48,7 +50,17 @@ impl Network { } } +pub fn discover_networks(config_path: &Path) -> anyhow::Result { + RegisteredNetworks::ensure_exists(config_path) +} + pub fn discover_env(config_path: &Path, network_name: &str) -> anyhow::Result { + tracing::trace!( + "Discovering network details: config_path={:?}, network_name={}", + config_path, + network_name + ); + // Lookup network discovery to bootstrap let discovery = Discovery::ensure_exists(config_path, network_name)?; diff --git a/nym-vpn-core/crates/nym-vpn-network-config/src/nym_network.rs b/nym-vpn-core/crates/nym-vpn-network-config/src/nym_network.rs index abb64cdc8d..728b5b454a 100644 --- a/nym-vpn-core/crates/nym-vpn-network-config/src/nym_network.rs +++ b/nym-vpn-core/crates/nym-vpn-network-config/src/nym_network.rs @@ -169,3 +169,16 @@ fn export_nym_network_details_to_env(network_details: NymNetworkDetails) { set_optional_var(var_names::EXPLORER_API, network_details.explorer_api); set_optional_var(var_names::NYM_VPN_API, network_details.nym_vpn_api_url); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nym_network_path() { + let config_dir = Path::new("/tmp"); + let network_name = "mainnet"; + let path = NymNetwork::path(config_dir, network_name); + assert_eq!(path, Path::new("/tmp/networks/mainnet.json")); + } +} diff --git a/nym-vpn-core/crates/nym-vpnd/src/environment.rs b/nym-vpn-core/crates/nym-vpnd/src/environment.rs index 7a4b8c693c..a6960c0639 100644 --- a/nym-vpn-core/crates/nym-vpnd/src/environment.rs +++ b/nym-vpn-core/crates/nym-vpnd/src/environment.rs @@ -23,6 +23,11 @@ pub(crate) fn setup_environment( } else { let network_name = global_config_file.network_name.clone(); let config_path = crate::service::config_dir(); + + tracing::info!("Setting up registered networks"); + let networks = nym_vpn_network_config::discover_networks(&config_path)?; + tracing::debug!("Registered networks: {}", networks); + tracing::info!("Setting up environment by discovering the network: {network_name}"); nym_vpn_network_config::discover_env(&config_path, &network_name)? }; diff --git a/nym-vpn-core/env/env.json b/nym-vpn-core/env/env.json deleted file mode 100644 index 7f9ef733de..0000000000 --- a/nym-vpn-core/env/env.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "environments": ["mainnet", "canary"] -}