diff --git a/nym-vpn-core/Cargo.lock b/nym-vpn-core/Cargo.lock index 63dd1da13a..d3daebe95a 100644 --- a/nym-vpn-core/Cargo.lock +++ b/nym-vpn-core/Cargo.lock @@ -4573,12 +4573,14 @@ name = "nym-vpn-network-config" version = "1.0.0-alpha.2" dependencies = [ "anyhow", + "futures-util", "itertools 0.13.0", "nym-config", "reqwest 0.11.27", "serde", "serde_json", "tempfile", + "time", "tokio", "tokio-util", "tracing", diff --git a/nym-vpn-core/crates/nym-vpn-account-controller/src/controller.rs b/nym-vpn-core/crates/nym-vpn-account-controller/src/controller.rs index 1c485bb06b..4ac9c9e845 100644 --- a/nym-vpn-core/crates/nym-vpn-account-controller/src/controller.rs +++ b/nym-vpn-core/crates/nym-vpn-account-controller/src/controller.rs @@ -389,7 +389,9 @@ where match self.account_storage.load_account().await { Ok(account) => { tracing::debug!("Our account id: {}", account.id()); - self.account_state.set_mnemonic(MnemonicState::Stored).await; + self.account_state + .set_mnemonic(MnemonicState::Stored { id: account.id() }) + .await; Some(account) } Err(err) => { @@ -428,7 +430,7 @@ where } self.account_state - .set_account(AccountState::from(account_summary.account.status)) + .set_account(AccountState::from(account_summary.account)) .await; self.account_state diff --git a/nym-vpn-core/crates/nym-vpn-account-controller/src/shared_state.rs b/nym-vpn-core/crates/nym-vpn-account-controller/src/shared_state.rs index df6c685ad2..2e49c28020 100644 --- a/nym-vpn-core/crates/nym-vpn-account-controller/src/shared_state.rs +++ b/nym-vpn-core/crates/nym-vpn-account-controller/src/shared_state.rs @@ -4,8 +4,8 @@ use std::{fmt, sync::Arc, time::Duration}; use nym_vpn_api_client::response::{ - NymVpnAccountStatusResponse, NymVpnAccountSummarySubscription, NymVpnDeviceStatus, - NymVpnSubscriptionStatus, + NymVpnAccountResponse, NymVpnAccountStatusResponse, NymVpnAccountSummarySubscription, + NymVpnDeviceStatus, NymVpnSubscriptionStatus, }; use serde::Serialize; use tokio::sync::MutexGuard; @@ -103,10 +103,15 @@ impl SharedAccountState { pub(crate) async fn is_ready_to_register_device(&self) -> ReadyToRegisterDevice { let state = self.lock().await.clone(); - if state.mnemonic != Some(MnemonicState::Stored) { + if !state + .mnemonic + .map(|m| matches!(m, MnemonicState::Stored { .. })) + .unwrap_or(false) + { return ReadyToRegisterDevice::NoMnemonicStored; } if state.account != Some(AccountState::Active) { + // if state.account.map(|a| !a.is_active()).unwrap_or(false) { return ReadyToRegisterDevice::AccountNotActive; } if state.subscription != Some(SubscriptionState::Active) { @@ -190,7 +195,7 @@ pub enum MnemonicState { NotStored, // The recovery phrase is stored locally - Stored, + Stored { id: String }, } #[derive(Debug, Clone, PartialEq, Serialize)] @@ -239,9 +244,21 @@ pub enum DeviceState { } impl AccountStateSummary { + pub fn account_id(&self) -> Option { + match &self.mnemonic { + Some(MnemonicState::Stored { id }) => Some(id.clone()), + _ => None, + } + } + // If we are ready right right now. fn is_ready_now(&self) -> ReadyToConnect { - if self.mnemonic != Some(MnemonicState::Stored) { + if !self + .mnemonic + .as_ref() + .map(|m| matches!(m, MnemonicState::Stored { .. })) + .unwrap_or(false) + { return ReadyToConnect::NoMnemonicStored; } if self.account != Some(AccountState::Active) { @@ -265,14 +282,14 @@ impl AccountStateSummary { fn is_ready(&self) -> Option { match self.mnemonic { Some(MnemonicState::NotStored) => return Some(ReadyToConnect::NoMnemonicStored), - Some(MnemonicState::Stored) => {} + Some(MnemonicState::Stored { .. }) => {} None => return None, } match self.account { Some(AccountState::NotRegistered) => return Some(ReadyToConnect::AccountNotActive), - Some(AccountState::Inactive) => return Some(ReadyToConnect::AccountNotActive), - Some(AccountState::DeleteMe) => return Some(ReadyToConnect::AccountNotActive), - Some(AccountState::Active) => {} + Some(AccountState::Inactive { .. }) => return Some(ReadyToConnect::AccountNotActive), + Some(AccountState::DeleteMe { .. }) => return Some(ReadyToConnect::AccountNotActive), + Some(AccountState::Active { .. }) => {} None => return None, } match self.subscription { @@ -318,9 +335,9 @@ fn debug_or_unknown(state: Option<&impl fmt::Debug>) -> String { .unwrap_or_else(|| "Unknown".to_string()) } -impl From for AccountState { - fn from(status: NymVpnAccountStatusResponse) -> Self { - match status { +impl From for AccountState { + fn from(account: NymVpnAccountResponse) -> Self { + match account.status { NymVpnAccountStatusResponse::Active => AccountState::Active, NymVpnAccountStatusResponse::Inactive => AccountState::Inactive, NymVpnAccountStatusResponse::DeleteMe => AccountState::DeleteMe, diff --git a/nym-vpn-core/crates/nym-vpn-lib/src/platform/account.rs b/nym-vpn-core/crates/nym-vpn-lib/src/platform/account.rs index ba14285db0..d4aed85568 100644 --- a/nym-vpn-core/crates/nym-vpn-lib/src/platform/account.rs +++ b/nym-vpn-core/crates/nym-vpn-lib/src/platform/account.rs @@ -4,6 +4,7 @@ use std::{path::PathBuf, str::FromStr, sync::Arc, time::Duration}; use nym_vpn_account_controller::{AccountCommand, ReadyToConnect, SharedAccountState}; +use nym_vpn_api_client::types::VpnApiAccount; use nym_vpn_store::{keys::KeyStore, mnemonic::MnemonicStorage}; use tokio::{sync::mpsc::UnboundedSender, task::JoinHandle}; use tokio_util::sync::CancellationToken; @@ -184,6 +185,18 @@ pub(super) async fn is_account_mnemonic_stored(path: &str) -> Result Result { + let storage = setup_account_storage(path)?; + storage + .load_mnemonic() + .await + .map(VpnApiAccount::from) + .map(|account| account.id()) + .map_err(|err| VpnError::InternalError { + details: err.to_string(), + }) +} + pub(super) async fn remove_account_mnemonic(path: &str) -> Result { // TODO: remove the mnemonic by sending a command to the account controller instead of directly // interacting with the storage. diff --git a/nym-vpn-core/crates/nym-vpn-lib/src/platform/mod.rs b/nym-vpn-core/crates/nym-vpn-lib/src/platform/mod.rs index 217e3cc25c..738560e50f 100644 --- a/nym-vpn-core/crates/nym-vpn-lib/src/platform/mod.rs +++ b/nym-vpn-core/crates/nym-vpn-lib/src/platform/mod.rs @@ -38,8 +38,9 @@ use crate::{ TunnelStateMachine, TunnelType, }, uniffi_custom_impls::{ - AccountStateSummary, BandwidthStatus, ConnectionStatus, EntryPoint, ExitPoint, - GatewayMinPerformance, GatewayType, Location, NetworkEnvironment, TunStatus, UserAgent, + AccountLinks, AccountStateSummary, BandwidthStatus, ConnectionStatus, EntryPoint, + ExitPoint, GatewayMinPerformance, GatewayType, Location, NetworkEnvironment, SystemMessage, + TunStatus, UserAgent, }, }; @@ -148,6 +149,59 @@ async fn fetch_environment(network_name: &str) -> Result Result, VpnError> { + RUNTIME.block_on(fetch_system_messages(network_name)) +} + +async fn fetch_system_messages(network_name: &str) -> Result, VpnError> { + nym_vpn_network_config::Network::fetch(network_name) + .map(|network| { + network + .nym_vpn_network + .system_messages + .into_current_iter() + .map(SystemMessage::from) + .collect() + }) + .map_err(|err| VpnError::InternalError { + details: err.to_string(), + }) +} + +#[allow(non_snake_case)] +#[uniffi::export] +pub fn fetchAccountLinks( + account_store_path: &str, + network_name: &str, + locale: &str, +) -> Result { + RUNTIME.block_on(fetch_account_links( + account_store_path, + network_name, + locale, + )) +} + +async fn fetch_account_links( + path: &str, + network_name: &str, + locale: &str, +) -> Result { + let account_id = account::get_account_id(path).await?; + nym_vpn_network_config::Network::fetch(network_name) + .and_then(|network| { + network + .nym_vpn_network + .try_into_parsed_links(locale, &account_id) + }) + .map(AccountLinks::from) + .map_err(|err| VpnError::InternalError { + details: err.to_string(), + }) +} + #[allow(non_snake_case)] #[uniffi::export] pub fn storeAccountMnemonic(mnemonic: String, path: String) -> Result<(), VpnError> { diff --git a/nym-vpn-core/crates/nym-vpn-lib/src/uniffi_custom_impls.rs b/nym-vpn-core/crates/nym-vpn-lib/src/uniffi_custom_impls.rs index b0422bb4a4..88d9274096 100644 --- a/nym-vpn-core/crates/nym-vpn-lib/src/uniffi_custom_impls.rs +++ b/nym-vpn-core/crates/nym-vpn-lib/src/uniffi_custom_impls.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only use std::{ + collections::HashMap, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, path::PathBuf, str::FromStr, @@ -258,6 +259,7 @@ impl UniffiCustomTypeConverter for OffsetDateTime { pub struct NetworkEnvironment { pub nym_network: NymNetworkDetails, pub nym_vpn_network: NymVpnNetwork, + pub feature_flags: Option, } impl From for NetworkEnvironment { @@ -265,6 +267,7 @@ impl From for NetworkEnvironment { NetworkEnvironment { nym_network: network.nym_network.network.into(), nym_vpn_network: network.nym_vpn_network.into(), + feature_flags: network.feature_flags.map(FeatureFlags::from), } } } @@ -375,6 +378,38 @@ impl From for NymVpnNetwork { } } +#[derive(uniffi::Record)] +pub struct FeatureFlags { + pub flags: HashMap, +} + +#[derive(uniffi::Enum)] +pub enum FlagValue { + Value(String), + Group(HashMap), +} + +impl From for FeatureFlags { + fn from(value: nym_vpn_network_config::FeatureFlags) -> Self { + FeatureFlags { + flags: value + .flags + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect(), + } + } +} + +impl From for FlagValue { + fn from(value: nym_vpn_network_config::feature_flags::FlagValue) -> Self { + match value { + nym_vpn_network_config::feature_flags::FlagValue::Value(v) => FlagValue::Value(v), + nym_vpn_network_config::feature_flags::FlagValue::Group(g) => FlagValue::Group(g), + } + } +} + #[derive(uniffi::Record)] pub struct Location { pub two_letter_iso_country_code: String, @@ -656,7 +691,7 @@ impl From for MnemonicS nym_vpn_account_controller::shared_state::MnemonicState::NotStored => { MnemonicState::NotStored } - nym_vpn_account_controller::shared_state::MnemonicState::Stored => { + nym_vpn_account_controller::shared_state::MnemonicState::Stored { .. } => { MnemonicState::Stored } } @@ -677,11 +712,13 @@ impl From for AccountSta nym_vpn_account_controller::shared_state::AccountState::NotRegistered => { AccountState::NotRegistered } - nym_vpn_account_controller::shared_state::AccountState::Inactive => { + nym_vpn_account_controller::shared_state::AccountState::Inactive { .. } => { AccountState::Inactive } - nym_vpn_account_controller::shared_state::AccountState::Active => AccountState::Active, - nym_vpn_account_controller::shared_state::AccountState::DeleteMe => { + nym_vpn_account_controller::shared_state::AccountState::Active { .. } => { + AccountState::Active + } + nym_vpn_account_controller::shared_state::AccountState::DeleteMe { .. } => { AccountState::DeleteMe } } @@ -739,3 +776,37 @@ impl From for DeviceState } } } + +#[derive(uniffi::Record, Clone, PartialEq)] +pub struct SystemMessage { + pub name: String, + pub message: String, + pub properties: HashMap, +} + +impl From for SystemMessage { + fn from(value: nym_vpn_network_config::SystemMessage) -> Self { + SystemMessage { + name: value.name, + message: value.message, + properties: value.properties.into_inner(), + } + } +} + +#[derive(uniffi::Record, Clone, PartialEq)] +pub struct AccountLinks { + pub sign_up: String, + pub sign_in: String, + pub account: String, +} + +impl From for AccountLinks { + fn from(value: nym_vpn_network_config::ParsedAccountLinks) -> Self { + AccountLinks { + sign_up: value.sign_up.to_string(), + sign_in: value.sign_in.to_string(), + account: value.account.to_string(), + } + } +} 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 8e496eb853..5755524fb9 100644 --- a/nym-vpn-core/crates/nym-vpn-network-config/Cargo.toml +++ b/nym-vpn-core/crates/nym-vpn-network-config/Cargo.toml @@ -20,10 +20,12 @@ nym-config.workspace = true serde.workspace = true serde_json.workspace = true tempfile.workspace = true +time = { workspace = true, features = ["serde-human-readable"] } tokio = { workspace = true, features = ["time", "macros"] } tokio-util.workspace = true tracing.workspace = true url = { workspace = true, features = ["serde"] } +futures-util = "0.3" [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 index 772d51dda4..c7a1145d15 100644 --- a/nym-vpn-core/crates/nym-vpn-network-config/build.rs +++ b/nym-vpn-core/crates/nym-vpn-network-config/build.rs @@ -67,6 +67,9 @@ fn default_mainnet_discovery() { 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"), + account_management: Default::default(), + feature_flags: Default::default(), + system_messages: Default::default(), }} }} }} diff --git a/nym-vpn-core/crates/nym-vpn-network-config/src/account_management.rs b/nym-vpn-core/crates/nym-vpn-network-config/src/account_management.rs new file mode 100644 index 0000000000..ee81027f2e --- /dev/null +++ b/nym-vpn-core/crates/nym-vpn-network-config/src/account_management.rs @@ -0,0 +1,92 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use url::Url; + +use crate::response::{AccountManagementPathsResponse, AccountManagementResponse}; + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AccountManagement { + pub(crate) url: Url, + pub(crate) paths: AccountManagementPaths, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) struct AccountManagementPaths { + pub(crate) sign_up: String, + pub(crate) sign_in: String, + pub(crate) account: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ParsedAccountLinks { + pub sign_up: Url, + pub sign_in: Url, + pub account: Url, +} + +impl AccountManagement { + pub fn sign_up_url(&self, locale: &str) -> Option { + self.url + .join(&self.paths.sign_up.replace("{locale}", locale)) + .ok() + } + + pub fn sign_in_url(&self, locale: &str) -> Option { + self.url + .join(&self.paths.sign_in.replace("{locale}", locale)) + .ok() + } + + pub fn account_url(&self, locale: &str, account_id: &str) -> Option { + self.url + .join( + &self + .paths + .account + .replace("{locale}", locale) + .replace("{account_id}", account_id), + ) + .ok() + } + + pub fn try_into_parsed_links( + self, + locale: &str, + account_id: &str, + ) -> Result { + Ok(ParsedAccountLinks { + sign_up: self + .sign_up_url(locale) + .ok_or_else(|| anyhow::anyhow!("Failed to parse sign up URL"))?, + sign_in: self + .sign_in_url(locale) + .ok_or_else(|| anyhow::anyhow!("Failed to parse sign in URL"))?, + account: self + .account_url(locale, account_id) + .ok_or_else(|| anyhow::anyhow!("Failed to parse account URL"))?, + }) + } +} + +impl TryFrom for AccountManagement { + type Error = anyhow::Error; + + fn try_from(response: AccountManagementResponse) -> Result { + let url = response.url.parse()?; + Ok(Self { + url, + paths: response.paths.into(), + }) + } +} + +impl From for AccountManagementPaths { + fn from(response: AccountManagementPathsResponse) -> Self { + Self { + sign_up: response.sign_up, + sign_in: response.sign_in, + account: response.account, + } + } +} 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 deleted file mode 100644 index d3b5f4de1a..0000000000 --- a/nym-vpn-core/crates/nym-vpn-network-config/src/bootstrap.rs +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright 2024 - Nym Technologies SA -// SPDX-License-Identifier: GPL-3.0-only - -use std::path::{Path, PathBuf}; - -use anyhow::Context; -use nym_config::defaults::NymNetworkDetails; -use url::Url; - -use super::{nym_network::NymNetwork, MAX_FILE_AGE, NETWORKS_SUBDIR}; - -// TODO: integrate with nym-vpn-api-client - -const DISCOVERY_FILE: &str = "discovery.json"; -const DISCOVERY_WELLKNOWN: &str = "https://nymvpn.com/api/public/v1/.wellknown"; - -#[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 - .join(NETWORKS_SUBDIR) - .join(format!("{}_{}", network_name, DISCOVERY_FILE)) - } - - pub(super) fn path_is_stale(config_dir: &Path, network_name: &str) -> anyhow::Result { - if let Some(age) = crate::util::get_age_of_file(&Self::path(config_dir, network_name))? { - Ok(age > MAX_FILE_AGE) - } else { - Ok(true) - } - } - - fn endpoint(network_name: &str) -> anyhow::Result { - format!( - "{}/{}/{}", - DISCOVERY_WELLKNOWN, network_name, DISCOVERY_FILE - ) - .parse() - .map_err(Into::into) - } - - 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); - let response = reqwest::blocking::get(url.clone()) - .inspect_err(|err| tracing::warn!("{}", err)) - .with_context(|| format!("Failed to fetch discovery from {}", url))? - .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") - } - discovery.try_into() - } - - pub(super) fn read_from_file(config_dir: &Path, network_name: &str) -> anyhow::Result { - let path = Self::path(config_dir, network_name); - tracing::info!("Reading discovery file from: {}", path.display()); - - let file_str = std::fs::read_to_string(path)?; - let network: Discovery = serde_json::from_str(&file_str)?; - Ok(network) - } - - pub(super) fn write_to_file(&self, config_dir: &Path) -> anyhow::Result<()> { - let path = Self::path(config_dir, &self.network_name); - tracing::info!("Writing discovery file to: {}", path.display()); - - // Create parent directories if they don't exist - 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 discovery file at {:?}", path))?; - - serde_json::to_writer_pretty(&file, self) - .with_context(|| format!("Failed to write discovery file at {:?}", path))?; - - 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. - - 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) - } - - pub fn fetch_nym_network_details(&self) -> anyhow::Result { - let url = format!("{}/v1/network/details", self.nym_api_url); - tracing::info!("Fetching nym network details from: {}", url); - let network_details: NymNetworkDetailsResponse = reqwest::blocking::get(&url) - .with_context(|| format!("Failed to fetch network details from {}", url))? - .json() - .with_context(|| "Failed to parse network details")?; - if network_details.network.network_name != self.network_name { - anyhow::bail!("Network name mismatch between requested and fetched network details") - } - Ok(NymNetwork { - network: network_details.network, - }) - } -} - -// The response type we fetch from the discovery endpoint -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -struct DiscoveryResponse { - network_name: String, - nym_api_url: String, - nym_vpn_api_url: String, -} - -// The response type we fetch from the network details endpoint. This will be added to and exported -// from nym-api-requests. -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -struct NymNetworkDetailsResponse { - network: NymNetworkDetails, -} - -impl TryFrom for Discovery { - type Error = anyhow::Error; - - fn try_from(discovery: DiscoveryResponse) -> anyhow::Result { - Ok(Self { - network_name: discovery.network_name, - nym_api_url: discovery.nym_api_url.parse()?, - nym_vpn_api_url: discovery.nym_vpn_api_url.parse()?, - }) - } -} - -#[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/discovery.rs b/nym-vpn-core/crates/nym-vpn-network-config/src/discovery.rs new file mode 100644 index 0000000000..d6e5010d47 --- /dev/null +++ b/nym-vpn-core/crates/nym-vpn-network-config/src/discovery.rs @@ -0,0 +1,365 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use url::Url; + +use crate::{ + response::{DiscoveryResponse, NymNetworkDetailsResponse, NymWellknownDiscoveryItem}, + AccountManagement, FeatureFlags, SystemMessages, +}; + +use super::{nym_network::NymNetwork, MAX_FILE_AGE, NETWORKS_SUBDIR}; + +// TODO: integrate with nym-vpn-api-client + +const DISCOVERY_FILE: &str = "discovery.json"; +const DISCOVERY_WELLKNOWN: &str = "https://nymvpn.com/api/public/v1/.wellknown"; + +const NYM_NETWORK_DETAILS_PATH: &str = "/v1/network/details"; +const NYM_VP_NETWORK_CURRENT_ENV_PATH: &str = "/public/v1/.wellknown/current-env.json"; + +fn nym_network_details_endpoint(nym_api_url: &Url) -> String { + format!("{}/{}", nym_api_url, NYM_NETWORK_DETAILS_PATH) +} + +fn nym_vpn_network_details_endpoint(nym_vpn_api_url: &Url) -> String { + format!("{}/{}", nym_vpn_api_url, NYM_VP_NETWORK_CURRENT_ENV_PATH) +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct Discovery { + // Base network setup + pub(super) network_name: String, + pub(super) nym_api_url: Url, + pub(super) nym_vpn_api_url: Url, + + // Additional context + pub(super) account_management: Option, + pub(super) feature_flags: Option, + pub(super) system_messages: SystemMessages, +} + +// 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 + .join(NETWORKS_SUBDIR) + .join(format!("{}_{}", network_name, DISCOVERY_FILE)) + } + + pub(super) fn path_is_stale(config_dir: &Path, network_name: &str) -> anyhow::Result { + if let Some(age) = crate::util::get_age_of_file(&Self::path(config_dir, network_name))? { + Ok(age > MAX_FILE_AGE) + } else { + Ok(true) + } + } + + fn endpoint(network_name: &str) -> anyhow::Result { + format!( + "{}/{}/{}", + DISCOVERY_WELLKNOWN, network_name, DISCOVERY_FILE + ) + .parse() + .map_err(Into::into) + } + + pub fn fetch(network_name: &str) -> anyhow::Result { + let discovery: DiscoveryResponse = { + let url = Self::endpoint(network_name)?; + + tracing::debug!("Fetching nym network discovery from: {}", url); + let response = reqwest::blocking::get(url.clone()) + .inspect_err(|err| tracing::warn!("{}", err)) + .with_context(|| format!("Failed to fetch discovery from {}", url))? + .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") + } + + tracing::debug!("Fetched nym network discovery: {:#?}", discovery); + discovery.try_into() + } + + pub(super) fn read_from_file(config_dir: &Path, network_name: &str) -> anyhow::Result { + let path = Self::path(config_dir, network_name); + tracing::debug!("Reading discovery file from: {}", path.display()); + + let file_str = std::fs::read_to_string(path)?; + let network: Discovery = serde_json::from_str(&file_str)?; + Ok(network) + } + + pub(super) fn write_to_file(&self, config_dir: &Path) -> anyhow::Result<()> { + let path = Self::path(config_dir, &self.network_name); + tracing::debug!("Writing discovery file to: {}", path.display()); + + // Create parent directories if they don't exist + 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 discovery file at {:?}", path))?; + + serde_json::to_writer_pretty(&file, self) + .with_context(|| format!("Failed to write discovery file at {:?}", path))?; + + 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. + + 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) + } + + pub fn fetch_nym_network_details(&self) -> anyhow::Result { + // TODO: integrate with validator-client and/or nym-vpn-api-client + let url = nym_network_details_endpoint(&self.nym_api_url); + tracing::debug!("Fetching nym network details from: {}", url); + let network_details: NymNetworkDetailsResponse = reqwest::blocking::get(url.clone()) + .with_context(|| format!("Failed to fetch network details from {}", url))? + .json() + .with_context(|| "Failed to parse network details")?; + if network_details.network.network_name != self.network_name { + anyhow::bail!("Network name mismatch between requested and fetched network details") + } + Ok(NymNetwork { + network: network_details.network, + }) + } +} + +impl TryFrom for Discovery { + type Error = anyhow::Error; + + fn try_from(discovery: DiscoveryResponse) -> anyhow::Result { + let account_management = discovery.account_management.and_then(|am| { + AccountManagement::try_from(am) + .inspect_err(|err| tracing::warn!("Failed to parse account management: {err}")) + .ok() + }); + + let feature_flags = discovery.feature_flags.and_then(|ff| { + FeatureFlags::try_from(ff) + .inspect_err(|err| tracing::warn!("Failed to parse feature flags: {err}")) + .ok() + }); + + let system_messages = discovery + .system_messages + .map(SystemMessages::from) + .unwrap_or_default(); + + Ok(Self { + network_name: discovery.network_name, + nym_api_url: discovery.nym_api_url.parse()?, + nym_vpn_api_url: discovery.nym_vpn_api_url.parse()?, + account_management, + feature_flags, + system_messages, + }) + } +} + +pub(crate) async fn fetch_nym_network_details( + nym_api_url: &Url, +) -> anyhow::Result { + // TODO: integrate with validator-client and/or nym-vpn-api-client + let url = nym_network_details_endpoint(nym_api_url); + tracing::debug!("Fetching nym network details from: {}", url); + reqwest::get(&url) + .await + .with_context(|| format!("Failed to fetch network details from {}", url))? + .json() + .await + .with_context(|| "Failed to parse network details") +} + +pub(crate) async fn fetch_nym_vpn_network_details( + nym_vpn_api_url: &Url, +) -> anyhow::Result { + // TODO: integrate with nym-vpn-api-client + let url = nym_vpn_network_details_endpoint(nym_vpn_api_url); + tracing::debug!("Fetching nym vpn network details from: {}", url); + reqwest::get(&url) + .await + .with_context(|| format!("Failed to fetch vpn network details from {url}"))? + .json() + .await + .with_context(|| "Failed to parse vpn network details") +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + + use crate::{ + account_management::AccountManagementPaths, feature_flags::FlagValue, + system_messages::Properties, SystemMessage, + }; + + 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::default(); + let fetched = Discovery::fetch(&default.network_name).unwrap(); + + // Only compare the base fields + assert_eq!(default.network_name, fetched.network_name); + assert_eq!(default.nym_api_url, fetched.nym_api_url); + assert_eq!(default.nym_vpn_api_url, fetched.nym_vpn_api_url); + } + + #[test] + fn test_parse_discovery_response() { + let json = r#"{ + "network_name": "qa", + "nym_api_url": "https://foo.ch/api/", + "nym_vpn_api_url": "https://bar.ch/api/", + "account_management": { + "url": "https://foobar.ch/", + "paths": { + "sign_up": "{locale}/account/create", + "sign_in": "{locale}/account/login", + "account": "{locale}/account/{account_id}" + } + }, + "feature_flags": { + "website": { + "showAccounts": "true" + }, + "zkNyms": { + "credentialMode": "false" + } + }, + "system_messages": [ + { + "name": "test_message", + "displayFrom": "2024-11-05T12:00:00.000Z", + "displayUntil": "", + "message": "This is a test message, no need to panic!", + "properties": { + "modal": "true" + } + } + ] + }"#; + let discovery: DiscoveryResponse = serde_json::from_str(json).unwrap(); + let network: Discovery = discovery.try_into().unwrap(); + + let expected_network = Discovery { + network_name: "qa".to_owned(), + nym_api_url: "https://foo.ch/api/".parse().unwrap(), + nym_vpn_api_url: "https://bar.ch/api/".parse().unwrap(), + account_management: Some(AccountManagement { + url: "https://foobar.ch/".parse().unwrap(), + paths: AccountManagementPaths { + sign_up: "{locale}/account/create".to_owned(), + sign_in: "{locale}/account/login".to_owned(), + account: "{locale}/account/{account_id}".to_owned(), + }, + }), + feature_flags: Some(FeatureFlags { + flags: HashMap::from([ + ( + "website".to_owned(), + FlagValue::Group(HashMap::from([( + "showAccounts".to_owned(), + "true".to_owned(), + )])), + ), + ( + "zkNyms".to_owned(), + FlagValue::Group(HashMap::from([( + "credentialMode".to_owned(), + "false".to_owned(), + )])), + ), + ]), + }), + system_messages: SystemMessages::from(vec![SystemMessage { + name: "test_message".to_owned(), + display_from: Some( + OffsetDateTime::parse("2024-11-05T12:00:00.000Z", &Rfc3339).unwrap(), + ), + display_until: None, + message: "This is a test message, no need to panic!".to_owned(), + properties: Properties::from(HashMap::from([( + "modal".to_owned(), + "true".to_owned(), + )])), + }]), + }; + assert_eq!(network, expected_network); + } +} 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 index 52c55bd9d4..54e90e0108 100644 --- a/nym-vpn-core/crates/nym-vpn-network-config/src/envs.rs +++ b/nym-vpn-core/crates/nym-vpn-network-config/src/envs.rs @@ -69,7 +69,7 @@ impl RegisteredNetworks { fn fetch() -> anyhow::Result { let url = Self::endpoint()?; - tracing::info!("Fetching registered networks from: {}", url); + tracing::debug!("Fetching registered networks from: {}", url); let response = reqwest::blocking::get(url.clone()) .inspect_err(|err| tracing::warn!("{}", err)) @@ -89,7 +89,7 @@ impl RegisteredNetworks { fn read_from_file(config_dir: &Path) -> anyhow::Result { let path = Self::path(config_dir); - tracing::info!( + tracing::debug!( "Reading registered networks from file: {:?}", path.display() ); @@ -101,7 +101,7 @@ impl RegisteredNetworks { 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()); + tracing::debug!("Writing registered networks to file: {:?}", path.display()); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) diff --git a/nym-vpn-core/crates/nym-vpn-network-config/src/feature_flags.rs b/nym-vpn-core/crates/nym-vpn-network-config/src/feature_flags.rs new file mode 100644 index 0000000000..ee64d6f456 --- /dev/null +++ b/nym-vpn-core/crates/nym-vpn-network-config/src/feature_flags.rs @@ -0,0 +1,144 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use std::{collections::HashMap, fmt}; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeatureFlags { + pub flags: HashMap, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FlagValue { + Value(String), + Group(HashMap), +} + +impl TryFrom for FeatureFlags { + type Error = serde_json::Error; + + fn try_from(value: serde_json::Value) -> Result { + HashMap::::deserialize(value).map(|flags| Self { flags }) + } +} + +impl fmt::Display for FeatureFlags { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{{ {} }}", + itertools::join( + self.flags + .iter() + .map(|(key, value)| { format!("{}: {}", key, value) }), + ", " + ) + ) + } +} + +impl fmt::Display for FlagValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FlagValue::Value(value) => write!(f, "{}", value), + FlagValue::Group(group) => { + write!( + f, + "{{ {} }}", + itertools::join( + group + .iter() + .map(|(key, value)| { format!("{}: {}", key, value) }), + ", " + ) + ) + } + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use super::*; + + #[test] + fn parse_flat_list() { + let json = r#"{ + "showaccounts": "true" + }"#; + let parsed: Value = serde_json::from_str(json).unwrap(); + let flags = FeatureFlags::try_from(parsed).unwrap(); + assert_eq!( + flags.flags["showaccounts"], + FlagValue::Value("true".to_string()) + ); + } + + #[test] + fn parse_nested_list() { + let json = r#"{ + "website": { + "showaccounts": "true", + "foo": "bar" + }, + "zknyms": { + "credentialmode": "false" + } + }"#; + let parsed: Value = serde_json::from_str(json).unwrap(); + let flags = FeatureFlags::try_from(parsed).unwrap(); + assert_eq!( + flags.flags["website"], + FlagValue::Group(HashMap::from([ + ("showaccounts".to_owned(), "true".to_owned()), + ("foo".to_owned(), "bar".to_owned()) + ])) + ); + assert_eq!( + flags.flags["zknyms"], + FlagValue::Group(HashMap::from([( + "credentialmode".to_owned(), + "false".to_owned() + )])) + ); + } + + #[test] + fn parse_mixed_list() { + let json = r#"{ + "showaccounts": "true", + "website": { + "showaccounts": "true", + "foo": "bar" + }, + "zknyms": { + "credentialmode": "false" + } + }"#; + let parsed: Value = serde_json::from_str(json).unwrap(); + let flags = FeatureFlags::try_from(parsed).unwrap(); + assert_eq!( + flags.flags["showaccounts"], + FlagValue::Value("true".to_string()) + ); + assert_eq!( + flags.flags["website"], + FlagValue::Group(HashMap::from([ + ("showaccounts".to_owned(), "true".to_owned()), + ("foo".to_owned(), "bar".to_owned()) + ])) + ); + assert_eq!( + flags.flags["zknyms"], + FlagValue::Group(HashMap::from([( + "credentialmode".to_owned(), + "false".to_owned() + )])) + ); + } +} 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 3e64354bcf..b5643c2577 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 @@ -1,31 +1,44 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -mod bootstrap; +pub mod feature_flags; +pub mod system_messages; + +pub(crate) mod response; + +mod account_management; +mod discovery; mod envs; mod nym_network; mod nym_vpn_network; mod refresh; mod util; -use envs::RegisteredNetworks; +pub use account_management::{AccountManagement, ParsedAccountLinks}; +pub use feature_flags::FeatureFlags; +use futures_util::FutureExt; pub use nym_network::NymNetwork; pub use nym_vpn_network::NymVpnNetwork; +pub use system_messages::{SystemMessage, SystemMessages}; -use bootstrap::Discovery; +use discovery::Discovery; +use envs::RegisteredNetworks; use nym_config::defaults::NymNetworkDetails; +use tokio::join; use std::{path::Path, time::Duration}; const NETWORKS_SUBDIR: &str = "networks"; // Refresh the discovery and network details files periodically -const MAX_FILE_AGE: Duration = Duration::from_secs(60 * 60 * 24); +//const MAX_FILE_AGE: Duration = Duration::from_secs(60 * 60 * 24); +const MAX_FILE_AGE: Duration = Duration::from_secs(60); #[derive(Clone, Debug)] pub struct Network { pub nym_network: NymNetwork, pub nym_vpn_network: NymVpnNetwork, + pub feature_flags: Option, } impl Network { @@ -38,16 +51,59 @@ impl Network { self.nym_vpn_network.export_to_env(); } + // Fetch network information directly from the endpoint without going through the path of first + // persisting to disk etc. + // Currently used on mobile only. pub fn fetch(network_name: &str) -> anyhow::Result { let discovery = Discovery::fetch(network_name)?; + let feature_flags = discovery.feature_flags.clone(); let nym_network = discovery.fetch_nym_network_details()?; let nym_vpn_network = NymVpnNetwork::from(discovery); Ok(Network { nym_network, nym_vpn_network, + feature_flags, }) } + + // Query the network name for both urls and check that it matches + // TODO: integrate with validator-client and/or nym-vpn-api-client + pub async fn check_consistency(&self) -> anyhow::Result { + tracing::debug!("Checking network consistency"); + let nym_api_url = self + .nym_network + .network + .endpoints + .first() + .and_then(|v| v.api_url()) + .ok_or(anyhow::anyhow!("No endpoints found"))?; + let network_name = discovery::fetch_nym_network_details(&nym_api_url) + .map(|resp| resp.map(|d| d.network.network_name)); + + let nym_vpn_api_url = self.nym_vpn_network.nym_vpn_api_url.clone(); + let vpn_network_name = discovery::fetch_nym_vpn_network_details(&nym_vpn_api_url) + .map(|resp| resp.map(|d| d.network_name)); + + let (network_name, vpn_network_name) = join!(network_name, vpn_network_name); + let network_name = network_name?; + let vpn_network_name = vpn_network_name?; + + tracing::debug!("nym network name: {network_name}"); + tracing::debug!("nym-vpn network name: {vpn_network_name}"); + Ok(network_name == vpn_network_name) + } + + pub fn api_url(&self) -> Option { + self.nym_network_details() + .endpoints + .first() + .and_then(|endpoint| endpoint.api_url()) + } + + pub fn vpn_api_url(&self) -> url::Url { + self.nym_vpn_network.nym_vpn_api_url.clone() + } } pub fn discover_networks(config_path: &Path) -> anyhow::Result { @@ -63,6 +119,17 @@ pub fn discover_env(config_path: &Path, network_name: &str) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result { let path = Self::path(config_dir, network_name); - tracing::info!("Reading network details from: {}", path.display()); + tracing::debug!("Reading network details from: {}", path.display()); let file_str = std::fs::read_to_string(path)?; let network: NymNetworkDetails = serde_json::from_str(&file_str)?; Ok(Self { network }) diff --git a/nym-vpn-core/crates/nym-vpn-network-config/src/nym_vpn_network.rs b/nym-vpn-core/crates/nym-vpn-network-config/src/nym_vpn_network.rs index db2e84b570..98badc1b55 100644 --- a/nym-vpn-core/crates/nym-vpn-network-config/src/nym_vpn_network.rs +++ b/nym-vpn-core/crates/nym-vpn-network-config/src/nym_vpn_network.rs @@ -6,23 +6,39 @@ use std::env; use nym_config::defaults::{var_names, NymNetworkDetails}; use url::Url; -use super::bootstrap::Discovery; +use crate::{AccountManagement, ParsedAccountLinks, SystemMessages}; + +use super::discovery::Discovery; #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct NymVpnNetwork { pub nym_vpn_api_url: Url, + pub account_management: Option, + pub system_messages: SystemMessages, } impl NymVpnNetwork { pub(super) fn export_to_env(&self) { env::set_var(var_names::NYM_VPN_API, self.nym_vpn_api_url.to_string()); } + + pub fn try_into_parsed_links( + self, + locale: &str, + account_id: &str, + ) -> Result { + self.account_management + .ok_or_else(|| anyhow::anyhow!("Account management is not available for this network"))? + .try_into_parsed_links(locale, account_id) + } } impl From for NymVpnNetwork { fn from(discovery: Discovery) -> Self { Self { nym_vpn_api_url: discovery.nym_vpn_api_url, + account_management: discovery.account_management, + system_messages: discovery.system_messages, } } } @@ -37,6 +53,10 @@ impl TryFrom<&NymNetworkDetails> for NymVpnNetwork { .ok_or_else(|| anyhow::anyhow!("Failed to find NYM_VPN_API_URL in the environment"))? .parse()?; - Ok(Self { nym_vpn_api_url }) + Ok(Self { + nym_vpn_api_url, + account_management: None, + system_messages: SystemMessages::default(), + }) } } diff --git a/nym-vpn-core/crates/nym-vpn-network-config/src/refresh.rs b/nym-vpn-core/crates/nym-vpn-network-config/src/refresh.rs index 517b9b49b3..dbdf734ff8 100644 --- a/nym-vpn-core/crates/nym-vpn-network-config/src/refresh.rs +++ b/nym-vpn-core/crates/nym-vpn-network-config/src/refresh.rs @@ -9,7 +9,7 @@ use std::{ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use super::bootstrap::Discovery; +use super::discovery::Discovery; fn refresh_discovery_file(config_dir: &Path, network_name: &str) -> anyhow::Result<()> { if !Discovery::path_is_stale(config_dir, network_name)? { diff --git a/nym-vpn-core/crates/nym-vpn-network-config/src/response.rs b/nym-vpn-core/crates/nym-vpn-network-config/src/response.rs new file mode 100644 index 0000000000..9762d75589 --- /dev/null +++ b/nym-vpn-core/crates/nym-vpn-network-config/src/response.rs @@ -0,0 +1,52 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use nym_config::defaults::NymNetworkDetails; + +// The response type we fetch from the discovery endpoint +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub(super) struct DiscoveryResponse { + pub(super) network_name: String, + pub(super) nym_api_url: String, + pub(super) nym_vpn_api_url: String, + pub(super) account_management: Option, + pub(super) feature_flags: Option, + pub(super) system_messages: Option>, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub(super) struct AccountManagementResponse { + pub(super) url: String, + pub(super) paths: AccountManagementPathsResponse, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub(super) struct AccountManagementPathsResponse { + pub(super) sign_up: String, + pub(super) sign_in: String, + pub(super) account: String, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(super) struct SystemMessageResponse { + pub(super) name: String, + pub(super) display_from: String, + pub(super) display_until: String, + pub(super) message: String, + pub(super) properties: serde_json::Value, +} + +// The response type we fetch from the network details endpoint. This will be added to and exported +// from nym-api-requests. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub(super) struct NymNetworkDetailsResponse { + pub(super) network: NymNetworkDetails, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub(super) struct NymWellknownDiscoveryItem { + pub(super) network_name: String, + pub(super) nym_api_url: String, + pub(super) nym_vpn_api_url: String, +} diff --git a/nym-vpn-core/crates/nym-vpn-network-config/src/system_messages.rs b/nym-vpn-core/crates/nym-vpn-network-config/src/system_messages.rs new file mode 100644 index 0000000000..5a30e17f93 --- /dev/null +++ b/nym-vpn-core/crates/nym-vpn-network-config/src/system_messages.rs @@ -0,0 +1,238 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use std::{collections::HashMap, fmt}; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + +use crate::response::SystemMessageResponse; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SystemMessages { + pub messages: Vec, +} + +impl SystemMessages { + pub fn current_iter(&self) -> impl Iterator { + self.messages.iter().filter(|msg| msg.is_current()) + } + + pub fn into_current_iter(self) -> impl Iterator { + self.messages.into_iter().filter(|msg| msg.is_current()) + } + + pub fn into_current_messages(self) -> SystemMessages { + self.into_current_iter().collect::>().into() + } +} + +impl IntoIterator for SystemMessages { + type Item = SystemMessage; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.messages.into_iter() + } +} + +impl<'a> IntoIterator for &'a SystemMessages { + type Item = &'a SystemMessage; + type IntoIter = std::slice::Iter<'a, SystemMessage>; + + fn into_iter(self) -> Self::IntoIter { + self.messages.iter() + } +} + +impl fmt::Display for SystemMessages { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{{[")?; + for message in self { + writeln!(f, " {},", message)?; + } + write!(f, "]}}") + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct SystemMessage { + pub name: String, + pub display_from: Option, + pub display_until: Option, + pub message: String, + pub properties: Properties, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Properties(HashMap); + +impl fmt::Display for Properties { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{{ {} }}", + itertools::join(self.0.iter().map(|(k, v)| format!("{}: {}", k, v)), ", ") + ) + } +} + +impl Properties { + pub fn into_inner(self) -> HashMap { + self.0 + } +} + +impl From> for Properties { + fn from(map: HashMap) -> Self { + Self(map) + } +} + +impl fmt::Display for SystemMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{{ name: \"{}\", message: \"{}\", properties: {} }}", + self.name, self.message, self.properties + ) + } +} + +impl SystemMessage { + pub fn is_current(&self) -> bool { + let now = OffsetDateTime::now_utc(); + self.display_from.map_or(true, |from| from <= now) + && self.display_until.map_or(true, |until| until >= now) + } +} + +impl From> for SystemMessages { + fn from(messages: Vec) -> Self { + Self { messages } + } +} + +impl From> for SystemMessages { + fn from(responses: Vec) -> Self { + Self { + messages: responses + .into_iter() + .filter_map(|m| { + SystemMessage::try_from(m) + .inspect_err(|err| tracing::warn!("Failed to parse system message: {err}")) + .ok() + }) + .collect(), + } + } +} + +impl TryFrom for SystemMessage { + type Error = anyhow::Error; + + fn try_from(response: SystemMessageResponse) -> Result { + let display_from = OffsetDateTime::parse(&response.display_from, &Rfc3339) + .with_context(|| format!("Failed to parse display_from: {}", response.display_from)) + .ok(); + let display_until = OffsetDateTime::parse(&response.display_until, &Rfc3339) + .with_context(|| format!("Failed to parse display_until: {}", response.display_until)) + .ok(); + + let properties = + Properties::deserialize(response.properties).unwrap_or(Properties::default()); + + Ok(Self { + name: response.name, + display_from, + display_until, + message: response.message, + properties, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_system_message() { + let json = r#"{ + "name": "test_message", + "displayFrom": "2024-11-05T12:00:00.000Z", + "displayUntil": "", + "message": "This is a test message, no need to panic!", + "properties": { + "modal": "true" + } + }"#; + let parsed: SystemMessageResponse = serde_json::from_str(json).unwrap(); + let message = SystemMessage::try_from(parsed).unwrap(); + assert_eq!( + message, + SystemMessage { + name: "test_message".to_string(), + display_from: Some( + OffsetDateTime::parse("2024-11-05T12:00:00.000Z", &Rfc3339).unwrap() + ), + display_until: None, + message: "This is a test message, no need to panic!".to_string(), + properties: Properties(HashMap::from_iter(vec![( + "modal".to_string(), + "true".to_string() + )])), + } + ); + } + + #[test] + fn check_current_message() { + let message = SystemMessage { + name: "test_message".to_string(), + // Yesterday + display_from: Some(OffsetDateTime::now_utc() - time::Duration::days(1)), + display_until: None, + message: "This is a test message, no need to panic!".to_string(), + properties: Properties(HashMap::from_iter(vec![( + "modal".to_string(), + "true".to_string(), + )])), + }; + assert!(message.is_current()); + } + + #[test] + fn check_future_message() { + let message = SystemMessage { + name: "test_message".to_string(), + // Tomorrow + display_from: Some(OffsetDateTime::now_utc() + time::Duration::days(1)), + display_until: None, + message: "This is a test message, no need to panic!".to_string(), + properties: Properties(HashMap::from_iter(vec![( + "modal".to_string(), + "true".to_string(), + )])), + }; + assert!(!message.is_current()); + } + + #[test] + fn check_expired_message() { + let message = SystemMessage { + name: "test_message".to_string(), + // Yesterday + display_from: Some(OffsetDateTime::now_utc() - time::Duration::days(1)), + // Today + display_until: Some(OffsetDateTime::now_utc()), + message: "This is a test message, no need to panic!".to_string(), + properties: Properties(HashMap::from_iter(vec![( + "modal".to_string(), + "true".to_string(), + )])), + }; + assert!(!message.is_current()); + } +} diff --git a/nym-vpn-core/crates/nym-vpnc/src/cli.rs b/nym-vpn-core/crates/nym-vpnc/src/cli.rs index fdd2f0916e..89303595e8 100644 --- a/nym-vpn-core/crates/nym-vpnc/src/cli.rs +++ b/nym-vpn-core/crates/nym-vpnc/src/cli.rs @@ -25,11 +25,14 @@ pub(crate) enum Command { Status, Info, SetNetwork(SetNetworkArgs), + GetSystemMessages, + GetFeatureFlags, StoreAccount(StoreAccountArgs), IsAccountStored, RemoveAccount, GetAccountId, GetAccountState, + GetAccountLinks(GetAccountLinksArgs), RefreshAccountState, IsReadyToConnect, ListenToStatus, @@ -154,9 +157,10 @@ pub(crate) struct StoreAccountArgs { } #[derive(Args)] -pub(crate) struct ApplyFreepassArgs { - /// The freepass code to be applied. - pub(crate) code: String, +pub(crate) struct GetAccountLinksArgs { + /// The locale to be used. + #[arg(long)] + pub(crate) locale: String, } #[derive(Args)] diff --git a/nym-vpn-core/crates/nym-vpnc/src/main.rs b/nym-vpn-core/crates/nym-vpnc/src/main.rs index d0c1702461..e569c80623 100644 --- a/nym-vpn-core/crates/nym-vpnc/src/main.rs +++ b/nym-vpn-core/crates/nym-vpnc/src/main.rs @@ -6,8 +6,9 @@ use clap::Parser; use nym_gateway_directory::GatewayType; use nym_vpn_proto::{ ConnectRequest, DisconnectRequest, Empty, FetchRawAccountSummaryRequest, - FetchRawDevicesRequest, GetAccountIdentityRequest, GetAccountStateRequest, - GetDeviceIdentityRequest, GetDeviceZkNymsRequest, InfoRequest, InfoResponse, + FetchRawDevicesRequest, GetAccountIdentityRequest, GetAccountLinksRequest, + GetAccountStateRequest, GetDeviceIdentityRequest, GetDeviceZkNymsRequest, + GetFeatureFlagsRequest, GetSystemMessagesRequest, InfoRequest, InfoResponse, IsAccountStoredRequest, IsReadyToConnectRequest, ListCountriesRequest, ListGatewaysRequest, RefreshAccountStateRequest, RegisterDeviceRequest, RemoveAccountRequest, RequestZkNymRequest, ResetDeviceIdentityRequest, SetNetworkRequest, StatusRequest, StoreAccountRequest, UserAgent, @@ -42,11 +43,14 @@ async fn main() -> Result<()> { Command::Status => status(client_type).await?, Command::Info => info(client_type).await?, Command::SetNetwork(ref args) => set_network(client_type, args).await?, + Command::GetSystemMessages => get_system_messages(client_type).await?, + Command::GetFeatureFlags => get_feature_flags(client_type).await?, Command::StoreAccount(ref store_args) => store_account(client_type, store_args).await?, Command::RefreshAccountState => refresh_account_state(client_type).await?, Command::IsAccountStored => is_account_stored(client_type).await?, Command::RemoveAccount => remove_account(client_type).await?, Command::GetAccountId => get_account_id(client_type).await?, + Command::GetAccountLinks(ref args) => get_account_links(client_type, args).await?, Command::GetAccountState => get_account_state(client_type).await?, Command::IsReadyToConnect => is_ready_to_connect(client_type).await?, Command::ListenToStatus => listen_to_status(client_type).await?, @@ -182,6 +186,22 @@ async fn set_network(client_type: ClientType, args: &cli::SetNetworkArgs) -> Res Ok(()) } +async fn get_system_messages(client_type: ClientType) -> Result<()> { + let mut client = vpnd_client::get_client(client_type).await?; + let request = tonic::Request::new(GetSystemMessagesRequest {}); + let response = client.get_system_messages(request).await?.into_inner(); + println!("{:#?}", response); + Ok(()) +} + +async fn get_feature_flags(client_type: ClientType) -> Result<()> { + let mut client = vpnd_client::get_client(client_type).await?; + let request = tonic::Request::new(GetFeatureFlagsRequest {}); + let response = client.get_feature_flags(request).await?.into_inner(); + println!("{:#?}", response); + Ok(()) +} + async fn store_account(client_type: ClientType, store_args: &cli::StoreAccountArgs) -> Result<()> { let mut client = vpnd_client::get_client(client_type).await?; let request = tonic::Request::new(StoreAccountRequest { @@ -225,6 +245,16 @@ async fn get_account_id(client_type: ClientType) -> Result<()> { Ok(()) } +async fn get_account_links(client_type: ClientType, args: &cli::GetAccountLinksArgs) -> Result<()> { + let mut client = vpnd_client::get_client(client_type).await?; + let request = tonic::Request::new(GetAccountLinksRequest { + locale: args.locale.clone(), + }); + let response = client.get_account_links(request).await?.into_inner(); + println!("{:#?}", response); + Ok(()) +} + async fn get_account_state(client_type: ClientType) -> Result<()> { let mut client = vpnd_client::get_client(client_type).await?; let request = tonic::Request::new(GetAccountStateRequest {}); diff --git a/nym-vpn-core/crates/nym-vpnd/src/command_interface/connection_handler.rs b/nym-vpn-core/crates/nym-vpnd/src/command_interface/connection_handler.rs index dbc5b14935..55fdff5656 100644 --- a/nym-vpn-core/crates/nym-vpnd/src/command_interface/connection_handler.rs +++ b/nym-vpn-core/crates/nym-vpnd/src/command_interface/connection_handler.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only use nym_vpn_account_controller::{AccountStateSummary, ReadyToConnect}; +use nym_vpn_network_config::{FeatureFlags, ParsedAccountLinks, SystemMessages}; use tokio::sync::{mpsc::UnboundedSender, oneshot}; use nym_vpn_api_client::{ @@ -49,6 +50,32 @@ impl CommandInterfaceConnectionHandler { Self { vpn_command_tx } } + pub(crate) async fn handle_info(&self) -> Result { + self.send_and_wait(VpnServiceCommand::Info, ()).await + } + + pub(crate) async fn handle_set_network( + &self, + network: String, + ) -> Result, VpnCommandSendError> { + self.send_and_wait(VpnServiceCommand::SetNetwork, network) + .await + } + + pub(crate) async fn handle_get_system_messages( + &self, + ) -> Result { + self.send_and_wait(VpnServiceCommand::GetSystemMessages, ()) + .await + } + + pub(crate) async fn handle_get_feature_flags( + &self, + ) -> Result, VpnCommandSendError> { + self.send_and_wait(VpnServiceCommand::GetFeatureFlags, ()) + .await + } + pub(crate) async fn handle_connect( &self, entry: Option, @@ -73,18 +100,6 @@ impl CommandInterfaceConnectionHandler { self.send_and_wait(VpnServiceCommand::Disconnect, ()).await } - pub(crate) async fn handle_info(&self) -> Result { - self.send_and_wait(VpnServiceCommand::Info, ()).await - } - - pub(crate) async fn handle_set_network( - &self, - network: String, - ) -> Result, VpnCommandSendError> { - self.send_and_wait(VpnServiceCommand::SetNetwork, network) - .await - } - pub(crate) async fn handle_status(&self) -> Result { self.send_and_wait(VpnServiceCommand::Status, ()).await } @@ -146,6 +161,14 @@ impl CommandInterfaceConnectionHandler { .await } + pub(crate) async fn handle_get_account_links( + &self, + locale: String, + ) -> Result, VpnCommandSendError> { + self.send_and_wait(VpnServiceCommand::GetAccountLinks, locale) + .await + } + pub(crate) async fn handle_get_account_state( &self, ) -> Result, VpnCommandSendError> { diff --git a/nym-vpn-core/crates/nym-vpnd/src/command_interface/listener.rs b/nym-vpn-core/crates/nym-vpnd/src/command_interface/listener.rs index c26cb6a9f6..2dde80ccbf 100644 --- a/nym-vpn-core/crates/nym-vpnd/src/command_interface/listener.rs +++ b/nym-vpn-core/crates/nym-vpnd/src/command_interface/listener.rs @@ -17,8 +17,10 @@ use nym_vpn_proto::{ ConnectionStatusUpdate, DisconnectRequest, DisconnectResponse, Empty, FetchRawAccountSummaryRequest, FetchRawAccountSummaryResponse, FetchRawDevicesRequest, FetchRawDevicesResponse, GetAccountIdentityRequest, GetAccountIdentityResponse, - GetAccountStateRequest, GetAccountStateResponse, GetDeviceIdentityRequest, - GetDeviceIdentityResponse, GetDeviceZkNymsRequest, GetDeviceZkNymsResponse, InfoRequest, + GetAccountLinksRequest, GetAccountLinksResponse, GetAccountStateRequest, + GetAccountStateResponse, GetDeviceIdentityRequest, GetDeviceIdentityResponse, + GetDeviceZkNymsRequest, GetDeviceZkNymsResponse, GetFeatureFlagsRequest, + GetFeatureFlagsResponse, GetSystemMessagesRequest, GetSystemMessagesResponse, InfoRequest, InfoResponse, IsAccountStoredRequest, IsAccountStoredResponse, IsReadyToConnectRequest, IsReadyToConnectResponse, ListCountriesRequest, ListCountriesResponse, ListGatewaysRequest, ListGatewaysResponse, RefreshAccountStateRequest, RefreshAccountStateResponse, @@ -32,10 +34,13 @@ use super::{ connection_handler::CommandInterfaceConnectionHandler, error::CommandInterfaceError, helpers::{parse_entry_point, parse_exit_point, threshold_into_percent}, + protobuf::info_response::into_account_management_links, }; use crate::{ command_interface::protobuf::{ - connection_state::into_is_ready_to_connect_response_type, gateway::into_user_agent, + connection_state::into_is_ready_to_connect_response_type, + gateway::into_user_agent, + info_response::{into_proto_feature_flags, into_proto_system_message}, }, service::{ConnectOptions, VpnServiceCommand, VpnServiceStateChange}, }; @@ -146,6 +151,41 @@ impl NymVpnd for CommandInterface { Ok(tonic::Response::new(response)) } + async fn get_system_messages( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + tracing::debug!("Got get system messages request"); + + let messages = CommandInterfaceConnectionHandler::new(self.vpn_command_tx.clone()) + .handle_get_system_messages() + .await?; + + let messages = messages + .into_current_iter() + .map(into_proto_system_message) + .collect(); + let response = GetSystemMessagesResponse { messages }; + + Ok(tonic::Response::new(response)) + } + + async fn get_feature_flags( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + tracing::debug!("Got get feature flags request"); + + let feature_flags = CommandInterfaceConnectionHandler::new(self.vpn_command_tx.clone()) + .handle_get_feature_flags() + .await? + .ok_or(tonic::Status::not_found("Feature flags not found"))?; + + Ok(tonic::Response::new(into_proto_feature_flags( + feature_flags, + ))) + } + async fn vpn_connect( &self, request: tonic::Request, @@ -475,6 +515,35 @@ impl NymVpnd for CommandInterface { Ok(tonic::Response::new(response)) } + async fn get_account_links( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let locale = request.into_inner().locale; + + let result = CommandInterfaceConnectionHandler::new(self.vpn_command_tx.clone()) + .handle_get_account_links(locale) + .await?; + + let response = match result { + Ok(account_links) => GetAccountLinksResponse { + res: Some(nym_vpn_proto::get_account_links_response::Res::Links( + into_account_management_links(account_links), + )), + }, + Err(err) => { + tracing::error!("Failed to get account links: {:?}", err); + GetAccountLinksResponse { + res: Some(nym_vpn_proto::get_account_links_response::Res::Error( + nym_vpn_proto::AccountError::from(err), + )), + } + } + }; + + Ok(tonic::Response::new(response)) + } + async fn get_account_state( &self, _request: tonic::Request, diff --git a/nym-vpn-core/crates/nym-vpnd/src/command_interface/protobuf/account.rs b/nym-vpn-core/crates/nym-vpnd/src/command_interface/protobuf/account.rs index 5225258255..cf564858f3 100644 --- a/nym-vpn-core/crates/nym-vpnd/src/command_interface/protobuf/account.rs +++ b/nym-vpn-core/crates/nym-vpnd/src/command_interface/protobuf/account.rs @@ -21,7 +21,7 @@ fn into_mnemonic( mnemonic: nym_vpn_account_controller::shared_state::MnemonicState, ) -> nym_vpn_proto::MnemonicState { match mnemonic { - nym_vpn_account_controller::shared_state::MnemonicState::Stored => { + nym_vpn_account_controller::shared_state::MnemonicState::Stored { .. } => { nym_vpn_proto::MnemonicState::Stored } nym_vpn_account_controller::shared_state::MnemonicState::NotStored => { @@ -172,6 +172,16 @@ impl From for nym_vpn_proto::AccountError { message: err.to_string(), details: hashmap! {}, }, + AccountError::AccountManagementNotConfigured => nym_vpn_proto::AccountError { + kind: AccountErrorType::Storage as i32, + message: err.to_string(), + details: hashmap! {}, + }, + AccountError::FailedToParseAccountLinks => nym_vpn_proto::AccountError { + kind: AccountErrorType::Storage as i32, + message: err.to_string(), + details: hashmap! {}, + }, } } } diff --git a/nym-vpn-core/crates/nym-vpnd/src/command_interface/protobuf/info_response.rs b/nym-vpn-core/crates/nym-vpnd/src/command_interface/protobuf/info_response.rs index e720a3c135..0ee733af2a 100644 --- a/nym-vpn-core/crates/nym-vpnd/src/command_interface/protobuf/info_response.rs +++ b/nym-vpn-core/crates/nym-vpnd/src/command_interface/protobuf/info_response.rs @@ -1,18 +1,20 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use nym_vpn_proto::InfoResponse; - use crate::service::VpnServiceInfo; -impl From for InfoResponse { +impl From for nym_vpn_proto::InfoResponse { fn from(info: VpnServiceInfo) -> Self { - let build_timestamp = info.build_timestamp.map(offset_datetime_to_timestamp); + let build_timestamp = info + .build_timestamp + .map(offset_datetime_into_proto_timestamp); - let nym_network = Some(to_nym_network_details(info.nym_network.clone())); - let nym_vpn_network = Some(to_nym_vpn_network_details(info.nym_vpn_network.clone())); + let nym_network = Some(into_proto_nym_network_details(info.nym_network.clone())); + let nym_vpn_network = Some(into_proto_nym_vpn_network_details( + info.nym_vpn_network.clone(), + )); - InfoResponse { + Self { version: info.version, build_timestamp, triple: info.triple, @@ -24,33 +26,33 @@ impl From for InfoResponse { } } -fn to_nym_network_details( +fn into_proto_nym_network_details( nym_network: nym_vpn_network_config::NymNetwork, ) -> nym_vpn_proto::NymNetworkDetails { nym_vpn_proto::NymNetworkDetails { network_name: nym_network.network.network_name, - chain_details: Some(to_chain_details(nym_network.network.chain_details)), + chain_details: Some(into_proto_chain_details(nym_network.network.chain_details)), endpoints: nym_network .network .endpoints .into_iter() - .map(validator_details_to_endpoints) + .map(validator_details_into_proto_endpoints) .collect(), - contracts: Some(to_nym_contracts(nym_network.network.contracts)), + contracts: Some(into_proto_nym_contracts(nym_network.network.contracts)), } } -fn to_chain_details( +fn into_proto_chain_details( chain_details: nym_vpn_lib::nym_config::defaults::ChainDetails, ) -> nym_vpn_proto::ChainDetails { nym_vpn_proto::ChainDetails { bech32_account_prefix: chain_details.bech32_account_prefix, - mix_denom: Some(to_denom_details(chain_details.mix_denom)), - stake_denom: Some(to_denom_details(chain_details.stake_denom)), + mix_denom: Some(into_proto_denom_details(chain_details.mix_denom)), + stake_denom: Some(into_proto_denom_details(chain_details.stake_denom)), } } -fn to_denom_details( +fn into_proto_denom_details( denom_details: nym_vpn_lib::nym_config::defaults::DenomDetailsOwned, ) -> nym_vpn_proto::DenomDetails { nym_vpn_proto::DenomDetails { @@ -60,7 +62,7 @@ fn to_denom_details( } } -fn to_nym_contracts( +fn into_proto_nym_contracts( contracts: nym_vpn_lib::nym_config::defaults::NymContracts, ) -> nym_vpn_proto::NymContracts { nym_vpn_proto::NymContracts { @@ -73,31 +75,82 @@ fn to_nym_contracts( } } -fn to_nym_vpn_network_details( +fn into_proto_nym_vpn_network_details( nym_vpn_network: nym_vpn_network_config::NymVpnNetwork, ) -> nym_vpn_proto::NymVpnNetworkDetails { nym_vpn_proto::NymVpnNetworkDetails { - nym_vpn_api_url: Some(string_to_url(nym_vpn_network.nym_vpn_api_url.to_string())), + nym_vpn_api_url: Some(into_proto_url(nym_vpn_network.nym_vpn_api_url)), } } -fn offset_datetime_to_timestamp(datetime: time::OffsetDateTime) -> prost_types::Timestamp { +fn offset_datetime_into_proto_timestamp(datetime: time::OffsetDateTime) -> prost_types::Timestamp { prost_types::Timestamp { seconds: datetime.unix_timestamp(), nanos: datetime.nanosecond() as i32, } } -fn validator_details_to_endpoints( +fn validator_details_into_proto_endpoints( validator_details: nym_vpn_lib::nym_config::defaults::ValidatorDetails, ) -> nym_vpn_proto::ValidatorDetails { nym_vpn_proto::ValidatorDetails { - nyxd_url: Some(string_to_url(validator_details.nyxd_url)), - websocket_url: validator_details.websocket_url.map(string_to_url), - api_url: validator_details.api_url.map(string_to_url), + nyxd_url: Some(string_into_proto_url(validator_details.nyxd_url)), + websocket_url: validator_details.websocket_url.map(string_into_proto_url), + api_url: validator_details.api_url.map(string_into_proto_url), } } -fn string_to_url(url: String) -> nym_vpn_proto::Url { +fn string_into_proto_url(url: String) -> nym_vpn_proto::Url { nym_vpn_proto::Url { url } } + +fn into_proto_url(url: url::Url) -> nym_vpn_proto::Url { + nym_vpn_proto::Url { + url: url.to_string(), + } +} + +pub(crate) fn into_proto_system_message( + system_message: nym_vpn_network_config::SystemMessage, +) -> nym_vpn_proto::SystemMessage { + nym_vpn_proto::SystemMessage { + name: system_message.name, + message: system_message.message, + properties: system_message.properties.into_inner(), + } +} + +pub(crate) fn into_account_management_links( + account_links: nym_vpn_network_config::ParsedAccountLinks, +) -> nym_vpn_proto::AccountManagement { + nym_vpn_proto::AccountManagement { + sign_up: Some(into_proto_url(account_links.sign_up)), + sign_in: Some(into_proto_url(account_links.sign_in)), + account: Some(into_proto_url(account_links.account)), + } +} + +pub(crate) fn into_proto_feature_flags( + feature_flags: nym_vpn_network_config::FeatureFlags, +) -> nym_vpn_proto::GetFeatureFlagsResponse { + let mut response = nym_vpn_proto::GetFeatureFlagsResponse { + flags: Default::default(), + groups: Default::default(), + }; + + for (k, v) in feature_flags.flags { + match v { + nym_vpn_network_config::feature_flags::FlagValue::Value(value) => { + response.flags.insert(k, value); + } + nym_vpn_network_config::feature_flags::FlagValue::Group(group) => { + let group = group.into_iter().collect(); + response + .groups + .insert(k, nym_vpn_proto::FeatureFlagGroup { map: group }); + } + } + } + + response +} diff --git a/nym-vpn-core/crates/nym-vpnd/src/environment.rs b/nym-vpn-core/crates/nym-vpnd/src/environment.rs index a6960c0639..907a048093 100644 --- a/nym-vpn-core/crates/nym-vpnd/src/environment.rs +++ b/nym-vpn-core/crates/nym-vpnd/src/environment.rs @@ -4,13 +4,7 @@ use nym_vpn_lib::nym_config::defaults::NymNetworkDetails; use nym_vpn_network_config::Network; -use crate::{cli::CliArgs, config::GlobalConfigFile, GLOBAL_NETWORK_DETAILS}; - -fn set_global_network_details(network_details: Network) -> anyhow::Result<()> { - GLOBAL_NETWORK_DETAILS - .set(network_details) - .map_err(|_| anyhow::anyhow!("Failed to set network details")) -} +use crate::{cli::CliArgs, config::GlobalConfigFile}; pub(crate) fn setup_environment( global_config_file: &GlobalConfigFile, @@ -24,7 +18,7 @@ pub(crate) fn setup_environment( let network_name = global_config_file.network_name.clone(); let config_path = crate::service::config_dir(); - tracing::info!("Setting up registered networks"); + tracing::debug!("Setting up registered networks"); let networks = nym_vpn_network_config::discover_networks(&config_path)?; tracing::debug!("Registered networks: {}", networks); @@ -32,8 +26,7 @@ pub(crate) fn setup_environment( nym_vpn_network_config::discover_env(&config_path, &network_name)? }; - // TODO: pass network_env explicitly instead of relying on being exported to env + // TODO: we need to export to env here to bridge the gap to older code. network_env.export_to_env(); - set_global_network_details(network_env.clone())?; Ok(network_env) } diff --git a/nym-vpn-core/crates/nym-vpnd/src/main.rs b/nym-vpn-core/crates/nym-vpnd/src/main.rs index 8a8f447109..75420e5dad 100644 --- a/nym-vpn-core/crates/nym-vpnd/src/main.rs +++ b/nym-vpn-core/crates/nym-vpnd/src/main.rs @@ -14,8 +14,6 @@ mod util; #[cfg(windows)] mod windows_service; -use std::sync::OnceLock; - use clap::Parser; use nym_vpn_network_config::Network; use service::NymVpnService; @@ -24,9 +22,6 @@ use tokio_util::sync::CancellationToken; use crate::{cli::CliArgs, command_interface::CommandInterfaceOptions, config::GlobalConfigFile}; -// Lazy initialized global NymNetworkDetails -static GLOBAL_NETWORK_DETAILS: OnceLock = OnceLock::new(); - fn main() -> anyhow::Result<()> { run() } @@ -43,9 +38,9 @@ fn run() -> anyhow::Result<()> { logging::setup_logging(args.command.run_as_service); - let _ = environment::setup_environment(&global_config_file, &args)?; + let network_env = environment::setup_environment(&global_config_file, &args)?; - run_inner(args) + run_inner(args, network_env) } #[cfg(windows)] @@ -58,21 +53,23 @@ fn run() -> anyhow::Result<()> { global_config_file.write_to_file()?; } - let _ = environment::setup_environment(&global_config_file, &args)?; + let network_env = environment::setup_environment(&global_config_file, &args)?; if args.command.is_any() { Ok(windows_service::start(args)?) } else { logging::setup_logging(false); - run_inner(args) + run_inner(args, network_env) } } -fn run_inner(args: CliArgs) -> anyhow::Result<()> { - runtime::new_runtime().block_on(run_inner_async(args)) +fn run_inner(args: CliArgs, network_env: Network) -> anyhow::Result<()> { + runtime::new_runtime().block_on(run_inner_async(args, network_env)) } -async fn run_inner_async(args: CliArgs) -> anyhow::Result<()> { +async fn run_inner_async(args: CliArgs, network_env: Network) -> anyhow::Result<()> { + network_env.check_consistency().await?; + let (state_changes_tx, state_changes_rx) = broadcast::channel(10); let (status_tx, status_rx) = broadcast::channel(10); let shutdown_token = CancellationToken::new(); @@ -92,6 +89,7 @@ async fn run_inner_async(args: CliArgs) -> anyhow::Result<()> { vpn_command_rx, status_tx, shutdown_token.child_token(), + network_env, ); let mut shutdown_join_set = shutdown_handler::install(shutdown_token); diff --git a/nym-vpn-core/crates/nym-vpnd/src/service/config.rs b/nym-vpn-core/crates/nym-vpnd/src/service/config.rs index 981dfd148e..f988478fea 100644 --- a/nym-vpn-core/crates/nym-vpnd/src/service/config.rs +++ b/nym-vpn-core/crates/nym-vpnd/src/service/config.rs @@ -7,7 +7,6 @@ use std::{fmt, fs, path::PathBuf}; use nym_vpn_lib::gateway_directory; use serde::{de::DeserializeOwned, Serialize}; -use tracing::info; #[cfg(not(windows))] const DEFAULT_DATA_DIR: &str = "/var/lib/nym-vpnd"; @@ -130,8 +129,8 @@ pub enum ConfigSetupError { #[error("failed to set permissions for directory {dir}: {error}")] SetPermissions { dir: PathBuf, error: std::io::Error }, - #[error("global network details not set")] - GlobalNetworkNotSet, + #[error("missing nym-api URL")] + MissingApiUrl, } #[derive(Debug, serde::Serialize, serde::Deserialize)] @@ -212,7 +211,7 @@ where file: file_path.clone(), error, })?; - info!("Config file updated at {:?}", file_path); + tracing::info!("Config file updated at {:?}", file_path); Ok(config) } @@ -221,7 +220,7 @@ pub(super) fn create_data_dir(data_dir: &PathBuf) -> Result<(), ConfigSetupError dir: data_dir.clone(), error, })?; - info!("Making sure data dir exists at {:?}", data_dir); + tracing::debug!("Making sure data dir exists at {:?}", data_dir); #[cfg(unix)] { diff --git a/nym-vpn-core/crates/nym-vpnd/src/service/error.rs b/nym-vpn-core/crates/nym-vpnd/src/service/error.rs index 47804f89fd..935dfd6b3b 100644 --- a/nym-vpn-core/crates/nym-vpnd/src/service/error.rs +++ b/nym-vpn-core/crates/nym-vpnd/src/service/error.rs @@ -402,6 +402,12 @@ pub enum AccountError { AccountControllerError { source: nym_vpn_account_controller::Error, }, + + #[error("account not configured")] + AccountManagementNotConfigured, + + #[error("failed to parse account links")] + FailedToParseAccountLinks, } #[derive(Debug, thiserror::Error)] diff --git a/nym-vpn-core/crates/nym-vpnd/src/service/vpn_service.rs b/nym-vpn-core/crates/nym-vpnd/src/service/vpn_service.rs index 2494e9e94c..1410a5463c 100644 --- a/nym-vpn-core/crates/nym-vpnd/src/service/vpn_service.rs +++ b/nym-vpn-core/crates/nym-vpnd/src/service/vpn_service.rs @@ -10,7 +10,9 @@ use std::{ }; use bip39::Mnemonic; -use nym_vpn_network_config::{NymNetwork, NymVpnNetwork}; +use nym_vpn_network_config::{ + FeatureFlags, Network, NymNetwork, NymVpnNetwork, ParsedAccountLinks, SystemMessages, +}; use serde::{Deserialize, Serialize}; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; use tokio::{ @@ -18,7 +20,6 @@ use tokio::{ task::JoinHandle, }; use tokio_util::sync::CancellationToken; -use url::Url; use nym_vpn_account_controller::{ AccountCommand, AccountController, AccountStateSummary, ReadyToConnect, SharedAccountState, @@ -29,7 +30,6 @@ use nym_vpn_api_client::{ }; use nym_vpn_lib::{ gateway_directory::{self, EntryPoint, ExitPoint}, - nym_config::defaults::NymNetworkDetails, tunnel_state_machine::{ ConnectionData, DnsOptions, GatewayPerformanceOptions, MixnetEvent, MixnetTunnelOptions, NymConfig, TunnelCommand, TunnelConnectionData, TunnelEvent, TunnelSettings, TunnelState, @@ -38,7 +38,7 @@ use nym_vpn_lib::{ MixnetClientConfig, NodeIdentity, Recipient, }; -use crate::{config::GlobalConfigFile, GLOBAL_NETWORK_DETAILS}; +use crate::config::GlobalConfigFile; use super::{ config::{ConfigSetupError, NetworkEnvironments, NymVpnServiceConfig, DEFAULT_CONFIG_FILE}, @@ -90,20 +90,28 @@ impl fmt::Display for ConnectedStateDetails { // Seed used to generate device identity keys type Seed = [u8; 32]; +type Locale = String; + #[allow(clippy::large_enum_variant)] pub enum VpnServiceCommand { + Info(oneshot::Sender, ()), + SetNetwork(oneshot::Sender>, String), + GetSystemMessages(oneshot::Sender, ()), + GetFeatureFlags(oneshot::Sender>, ()), Connect( oneshot::Sender>, (ConnectArgs, nym_vpn_lib::UserAgent), ), Disconnect(oneshot::Sender>, ()), Status(oneshot::Sender, ()), - Info(oneshot::Sender, ()), - SetNetwork(oneshot::Sender>, String), StoreAccount(oneshot::Sender>, String), IsAccountStored(oneshot::Sender>, ()), RemoveAccount(oneshot::Sender>, ()), GetAccountIdentity(oneshot::Sender>, ()), + GetAccountLinks( + oneshot::Sender>, + Locale, + ), GetAccountState( oneshot::Sender>, (), @@ -128,17 +136,20 @@ pub enum VpnServiceCommand { impl fmt::Display for VpnServiceCommand { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + VpnServiceCommand::Info(..) => write!(f, "Info"), + VpnServiceCommand::SetNetwork(..) => write!(f, "SetNetwork"), + VpnServiceCommand::GetSystemMessages(..) => write!(f, "GetSystemMessages"), + VpnServiceCommand::GetFeatureFlags(..) => write!(f, "GetFeatureFlags"), VpnServiceCommand::Connect(_, (args, user_agent)) => { write!(f, "Connect {{ {args:?}, {user_agent:?} }}") } VpnServiceCommand::Disconnect(..) => write!(f, "Disconnect"), VpnServiceCommand::Status(..) => write!(f, "Status"), - VpnServiceCommand::Info(..) => write!(f, "Info"), - VpnServiceCommand::SetNetwork(..) => write!(f, "SetNetwork"), VpnServiceCommand::StoreAccount(..) => write!(f, "StoreAccount"), VpnServiceCommand::IsAccountStored(..) => write!(f, "IsAccountStored"), VpnServiceCommand::RemoveAccount(..) => write!(f, "RemoveAccount"), VpnServiceCommand::GetAccountIdentity(..) => write!(f, "GetAccountIdentity"), + VpnServiceCommand::GetAccountLinks(..) => write!(f, "GetAccountLinks"), VpnServiceCommand::GetAccountState(..) => write!(f, "GetAccountState"), VpnServiceCommand::RefreshAccountState(..) => write!(f, "RefreshAccountState"), VpnServiceCommand::IsReadyToConnect(..) => write!(f, "IsReadyToConnect"), @@ -338,6 +349,9 @@ pub(crate) struct NymVpnService where S: nym_vpn_store::VpnStorage, { + // The network environment + network_env: Network, + // The account state, updated by the account controller shared_account_state: SharedAccountState, @@ -378,6 +392,7 @@ impl NymVpnService { vpn_command_rx: mpsc::UnboundedReceiver, status_tx: broadcast::Sender, shutdown_token: CancellationToken, + network_env: Network, ) -> JoinHandle<()> { tracing::info!("Starting VPN service"); tokio::spawn(async { @@ -386,6 +401,7 @@ impl NymVpnService { vpn_command_rx, status_tx, shutdown_token, + network_env, ) .await { @@ -413,12 +429,9 @@ impl NymVpnService { vpn_command_rx: mpsc::UnboundedReceiver, status_tx: broadcast::Sender, shutdown_token: CancellationToken, + network_env: Network, ) -> Result { - let network_details = GLOBAL_NETWORK_DETAILS - .get() - .ok_or(Error::ConfigSetup(ConfigSetupError::GlobalNetworkNotSet))? - .clone(); - let network_name = network_details.nym_network_details().network_name.clone(); + let network_name = network_env.nym_network_details().network_name.clone(); let config_dir = super::config::config_dir().join(&network_name); let config_file = config_dir.join(DEFAULT_CONFIG_FILE); @@ -451,9 +464,17 @@ impl NymVpnService { let (event_sender, event_receiver) = mpsc::unbounded_channel(); let tunnel_settings = TunnelSettings::default(); + let api_url = network_env + .api_url() + .ok_or(Error::ConfigSetup(ConfigSetupError::MissingApiUrl))?; + let gateway_config = gateway_directory::Config { + api_url, + nym_vpn_api_url: Some(network_env.vpn_api_url()), + min_gateway_performance: None, + }; let nym_config = NymConfig { data_path: Some(data_dir.clone()), - gateway_config: gateway_directory::Config::new_from_env(), + gateway_config, }; let state_machine_handle = TunnelStateMachine::spawn( @@ -467,6 +488,7 @@ impl NymVpnService { .map_err(Error::StateMachine)?; Ok(Self { + network_env, shared_account_state, vpn_command_rx, vpn_state_changes_tx, @@ -533,6 +555,22 @@ where async fn handle_service_command(&mut self, command: VpnServiceCommand) { match command { + VpnServiceCommand::Info(tx, ()) => { + let result = self.handle_info().await; + let _ = tx.send(result); + } + VpnServiceCommand::SetNetwork(tx, network) => { + let result = self.handle_set_network(network).await; + let _ = tx.send(result); + } + VpnServiceCommand::GetSystemMessages(tx, ()) => { + let result = self.handle_get_system_messages().await; + let _ = tx.send(result); + } + VpnServiceCommand::GetFeatureFlags(tx, ()) => { + let result = self.handle_get_feature_flags().await; + let _ = tx.send(result); + } VpnServiceCommand::Connect(tx, (connect_args, user_agent)) => { let result = self.handle_connect(connect_args, user_agent).await; let _ = tx.send(result); @@ -545,14 +583,6 @@ where let result = self.handle_status().await; let _ = tx.send(result); } - VpnServiceCommand::Info(tx, ()) => { - let result = self.handle_info().await; - let _ = tx.send(result); - } - VpnServiceCommand::SetNetwork(tx, network) => { - let result = self.handle_set_network(network).await; - let _ = tx.send(result); - } VpnServiceCommand::StoreAccount(tx, account) => { let result = self.handle_store_account(account).await; let _ = tx.send(result); @@ -569,6 +599,10 @@ where let result = self.handle_get_account_identity().await; let _ = tx.send(result); } + VpnServiceCommand::GetAccountLinks(tx, locale) => { + let result = self.handle_get_account_links(locale).await; + let _ = tx.send(result); + } VpnServiceCommand::GetAccountState(tx, ()) => { let result = self.handle_get_account_state().await; let _ = tx.send(result); @@ -777,11 +811,6 @@ where } async fn handle_info(&self) -> VpnServiceInfo { - // TODO: remove expect - let network = GLOBAL_NETWORK_DETAILS - .get() - .expect("Incorrect environment setup") - .clone(); let bin_info = nym_bin_common::bin_info_local_vergen!(); let user_agent = crate::util::construct_user_agent(); @@ -791,8 +820,8 @@ where triple: bin_info.cargo_triple.to_string(), platform: user_agent.platform, git_commit: bin_info.commit_sha.to_string(), - nym_network: network.nym_network.clone(), - nym_vpn_network: network.nym_vpn_network.clone(), + nym_network: self.network_env.nym_network.clone(), + nym_vpn_network: self.network_env.nym_vpn_network.clone(), } } @@ -821,6 +850,14 @@ where Ok(()) } + async fn handle_get_system_messages(&self) -> SystemMessages { + self.network_env.nym_vpn_network.system_messages.clone() + } + + async fn handle_get_feature_flags(&self) -> Option { + self.network_env.feature_flags.clone() + } + async fn handle_store_account(&mut self, account: String) -> Result<(), AccountError> { self.storage .lock() @@ -874,6 +911,25 @@ where self.load_account().await.map(|account| account.id()) } + async fn handle_get_account_links( + &self, + locale: String, + ) -> Result { + let account = self.load_account().await?; + let account_id = account.id(); + + self.network_env + .nym_vpn_network + .account_management + .clone() + .ok_or(AccountError::AccountManagementNotConfigured)? + .try_into_parsed_links(&locale, &account_id) + .map_err(|err| { + tracing::error!("Failed to parse account links: {:?}", err); + AccountError::FailedToParseAccountLinks + }) + } + async fn handle_get_account_state(&self) -> Result { Ok(self.shared_account_state.lock().await.clone()) } @@ -981,7 +1037,7 @@ where let account = self.load_account().await?; // Setup client - let nym_vpn_api_url = get_nym_vpn_api_url()?; + let nym_vpn_api_url = self.network_env.vpn_api_url(); let user_agent = crate::util::construct_user_agent(); let api_client = nym_vpn_api_client::VpnApiClient::new(nym_vpn_api_url, user_agent)?; @@ -1000,19 +1056,10 @@ where let account = self.load_account().await?; // Setup client - let nym_vpn_api_url = get_nym_vpn_api_url()?; + let nym_vpn_api_url = self.network_env.vpn_api_url(); let user_agent = crate::util::construct_user_agent(); let api_client = nym_vpn_api_client::VpnApiClient::new(nym_vpn_api_url, user_agent)?; api_client.get_devices(&account).await.map_err(Into::into) } } - -fn get_nym_vpn_api_url() -> Result { - NymNetworkDetails::new_from_env() - .nym_vpn_api_url - .ok_or(AccountError::MissingApiUrl)? - .parse() - .map_err(|_| AccountError::InvalidApiUrl) - .inspect(|url| tracing::info!("Using nym-vpn-api url: {}", url)) -} diff --git a/nym-vpn-core/crates/nym-vpnd/src/windows_service/service.rs b/nym-vpn-core/crates/nym-vpnd/src/windows_service/service.rs index dbb748755e..db8471fc07 100644 --- a/nym-vpn-core/crates/nym-vpnd/src/windows_service/service.rs +++ b/nym-vpn-core/crates/nym-vpnd/src/windows_service/service.rs @@ -39,6 +39,24 @@ fn service_main(arguments: Vec) { async fn run_service(_arguments: Vec) -> windows_service::Result<()> { tracing::info!("Setting up event handler"); + // TODO: network selection is not yet implemented/supported + let network_name = "mainnet"; + let network_env = match nym_vpn_network_config::Network::fetch(network_name) { + Ok(network_env) => { + network_env.export_to_env(); + network_env + } + Err(err) => { + tracing::error!( + "Failed to fetch network environment for '{}': {}", + network_name, + err + ); + // TODO: just picking something here to make it compile + return Err(windows_service::Error::LaunchArgumentsNotSupported); + } + }; + let shutdown_token = CancellationToken::new(); let cloned_shutdown_token = shutdown_token.clone(); let event_handler = move |control_event| -> ServiceControlHandlerResult { @@ -89,6 +107,7 @@ async fn run_service(_arguments: Vec) -> windows_service::Result<()> { vpn_command_rx, status_tx, shutdown_token.child_token(), + network_env, ); tracing::info!("Service has started"); diff --git a/proto/nym/vpn.proto b/proto/nym/vpn.proto index cd64d3c9f1..55448cf765 100644 --- a/proto/nym/vpn.proto +++ b/proto/nym/vpn.proto @@ -124,6 +124,12 @@ message NymVpnNetworkDetails { Url nym_vpn_api_url = 1; } +message AccountManagement { + Url sign_up = 1; + Url sign_in = 2; + Url account = 3; +} + message ValidatorDetails { Url nyxd_url = 1; Url websocket_url = 2; @@ -167,6 +173,29 @@ message SetNetworkRequestError { string message = 2; } +message SystemMessage { + string name = 1; + string message = 2; + map properties = 3; +} + +message GetSystemMessagesRequest {} + +message GetSystemMessagesResponse { + repeated SystemMessage messages = 1; +} + +message GetFeatureFlagsRequest {} + +message GetFeatureFlagsResponse { + map flags = 1; + map groups = 2; +} + +message FeatureFlagGroup { + map map = 1; +} + message Threshold { uint32 min_performance = 1; } @@ -594,6 +623,17 @@ message GetAccountIdentityResponse { } } +message GetAccountLinksRequest { + string locale = 1; +} + +message GetAccountLinksResponse { + oneof res { + AccountManagement links = 1; + AccountError error = 2; + } +} + enum MnemonicState { MNEMONIC_STATE_UNSPECIFIED = 0; MNEMONIC_STATE_NOT_STORED = 1; @@ -755,6 +795,11 @@ service NymVpnd { // Set the network. This requires a restart to take effect rpc SetNetwork (SetNetworkRequest) returns (SetNetworkResponse) {} + // List messages fetched from nym-vpn-api + rpc GetSystemMessages (GetSystemMessagesRequest) returns (GetSystemMessagesResponse) {} + + rpc GetFeatureFlags (GetFeatureFlagsRequest) returns (GetFeatureFlagsResponse) {} + // Start the tunnel and connect rpc VpnConnect (ConnectRequest) returns (ConnectResponse) {} @@ -787,6 +832,7 @@ service NymVpnd { rpc IsAccountStored (IsAccountStoredRequest) returns (IsAccountStoredResponse) {} rpc RemoveAccount (RemoveAccountRequest) returns (RemoveAccountResponse) {} rpc GetAccountIdentity (GetAccountIdentityRequest) returns (GetAccountIdentityResponse) {} + rpc GetAccountLinks (GetAccountLinksRequest) returns (GetAccountLinksResponse) {} // Query the account state, which refers to the server side account, as it is // known and interpreted by nym-vpnd