From 4b3dcb6eca574102834c67b744dd67c825fce761 Mon Sep 17 00:00:00 2001 From: Simon Wicky Date: Tue, 19 Mar 2024 15:49:29 +0100 Subject: [PATCH] Feature Noise XKpsk3 : Fix localnet (#4465) * add custom mixnet option for gateways * add custom mixnet option for mixnode * change topology construction as to not miss described_nodes * adapt localnet scripts to new topology * add snake_case alias to described_nodes --- .../src/geo_aware_provider.rs | 10 +- .../topology-control/src/nym_api_provider.rs | 3 +- common/topology/src/lib.rs | 26 +++- common/topology/src/serde.rs | 16 +- gateway/src/commands/run.rs | 18 ++- gateway/src/error.rs | 9 ++ gateway/src/node/mod.rs | 34 ++++- mixnode/src/commands/run.rs | 9 ++ mixnode/src/error.rs | 9 ++ mixnode/src/node/mod.rs | 30 +++- scripts/build_topology.py | 138 +++++++++++++++++- scripts/localnet_start.sh | 8 +- 12 files changed, 275 insertions(+), 35 deletions(-) diff --git a/common/topology-control/src/geo_aware_provider.rs b/common/topology-control/src/geo_aware_provider.rs index d61309a755..5ebf39bec2 100644 --- a/common/topology-control/src/geo_aware_provider.rs +++ b/common/topology-control/src/geo_aware_provider.rs @@ -294,6 +294,14 @@ impl GeoAwareTopologyProvider { Ok(gateways) => gateways, }; + let nodes_described = match self.validator_client.get_cached_described_nodes().await { + Err(err) => { + error!("failed to get described nodes - {err}"); + return None; + } + Ok(epoch) => epoch, + }; + // Also fetch mixnodes cached by explorer-api, with the purpose of getting their // geolocation. debug!("Fetching mixnodes from explorer-api..."); @@ -353,7 +361,7 @@ impl GeoAwareTopologyProvider { .filter(|m| filtered_mixnode_ids.contains(&m.mix_id())) .collect::>(); - let topology = nym_topology_from_detailed(mixnodes, gateways) + let topology = nym_topology_from_detailed(mixnodes, gateways, nodes_described) .filter_system_version(&self.client_version); // TODO: return real error type diff --git a/common/topology-control/src/nym_api_provider.rs b/common/topology-control/src/nym_api_provider.rs index 829a239210..782404b253 100644 --- a/common/topology-control/src/nym_api_provider.rs +++ b/common/topology-control/src/nym_api_provider.rs @@ -85,8 +85,7 @@ impl NymApiTopologyProvider { Ok(epoch) => epoch, }; - let topology = nym_topology_from_detailed(mixnodes, gateways) - .with_described_nodes(nodes_described.clone()) + let topology = nym_topology_from_detailed(mixnodes, gateways, nodes_described.clone()) .filter_system_version(&self.client_version); if let Err(err) = self.check_layer_distribution(&topology) { diff --git a/common/topology/src/lib.rs b/common/topology/src/lib.rs index ebc002a877..652bfd0370 100644 --- a/common/topology/src/lib.rs +++ b/common/topology/src/lib.rs @@ -119,11 +119,15 @@ pub struct NymTopology { } impl NymTopology { - pub fn new(mixes: BTreeMap>, gateways: Vec) -> Self { + pub fn new( + mixes: BTreeMap>, + gateways: Vec, + described_nodes: Vec, + ) -> Self { NymTopology { mixes: mixes.clone(), gateways: gateways.clone(), - described_nodes: Vec::new(), + described_nodes: described_nodes.clone(), } } @@ -140,7 +144,11 @@ impl NymTopology { self } - pub fn new_unordered(unordered_mixes: Vec, gateways: Vec) -> Self { + pub fn new_unordered( + unordered_mixes: Vec, + gateways: Vec, + described_nodes: Vec, + ) -> Self { let mut mixes = BTreeMap::new(); for node in unordered_mixes.into_iter() { let layer = node.layer as MixLayer; @@ -148,7 +156,7 @@ impl NymTopology { layer_entry.push(node) } - NymTopology::new(mixes, gateways) + NymTopology::new(mixes, gateways, described_nodes) } #[cfg(feature = "serializable")] @@ -160,8 +168,9 @@ impl NymTopology { pub fn from_detailed( mix_details: Vec, gateway_bonds: Vec, + described_nodes: Vec, ) -> Self { - nym_topology_from_detailed(mix_details, gateway_bonds) + nym_topology_from_detailed(mix_details, gateway_bonds, described_nodes) } pub fn find_mix(&self, mix_id: MixId) -> Option<&mix::Node> { @@ -492,6 +501,7 @@ impl IntoGatewayNode for DescribedGateway { pub fn nym_topology_from_detailed( mix_details: Vec, gateway_bonds: Vec, + described_nodes: Vec, ) -> NymTopology where G: IntoGatewayNode, @@ -535,7 +545,7 @@ where } } - NymTopology::new(mixes, gateways) + NymTopology::new(mixes, gateways, described_nodes) } #[cfg(test)] @@ -582,7 +592,7 @@ mod converting_mixes_to_vec { mixes.insert(1, vec![node1, node2]); mixes.insert(2, vec![node3]); - let topology = NymTopology::new(mixes, vec![]); + let topology = NymTopology::new(mixes, vec![], vec![]); let mixvec = topology.mixes_as_vec(); assert!(mixvec.iter().any(|node| node.owner == "N/A")); } @@ -594,7 +604,7 @@ mod converting_mixes_to_vec { #[test] fn returns_an_empty_vec() { - let topology = NymTopology::new(BTreeMap::new(), vec![]); + let topology = NymTopology::new(BTreeMap::new(), vec![], vec![]); let mixvec = topology.mixes_as_vec(); assert!(mixvec.is_empty()); } diff --git a/common/topology/src/serde.rs b/common/topology/src/serde.rs index 2e0db140f1..7d777bc6b6 100644 --- a/common/topology/src/serde.rs +++ b/common/topology/src/serde.rs @@ -4,6 +4,7 @@ use crate::gateway::GatewayConversionError; use crate::mix::MixnodeConversionError; use crate::{gateway, mix, MixLayer, NymTopology}; +use nym_api_requests::models::DescribedNymNode; use nym_config::defaults::{DEFAULT_CLIENT_LISTENING_PORT, DEFAULT_MIX_LISTENING_PORT}; use nym_crypto::asymmetric::{encryption, identity}; use serde::{Deserialize, Serialize}; @@ -53,6 +54,12 @@ impl From for JsValue { pub struct SerializableNymTopology { pub mixnodes: BTreeMap>, pub gateways: Vec, + + //SW NOTE : make this an option to keep backwards compatibility. Noise with fallback needs that to work though + // Once fallback is removed, we only need a list of unfiltered nodes that can be constructed from mixnodes and gateways + // depending on the usecase of this struct + #[serde(alias = "described_nodes")] + pub described_nodes: Option>, //DescribedNymNode is already Serialize and Deserialize } impl TryFrom for NymTopology { @@ -76,7 +83,11 @@ impl TryFrom for NymTopology { .map(TryInto::try_into) .collect::>()?; - Ok(NymTopology::new(converted_mixes, gateways)) + Ok(NymTopology::new( + converted_mixes, + gateways, + value.described_nodes.unwrap_or_default(), + )) } } @@ -89,6 +100,7 @@ impl From for SerializableNymTopology { .map(|(&l, nodes)| (l, nodes.iter().map(Into::into).collect())) .collect(), gateways: value.gateways().iter().map(Into::into).collect(), + described_nodes: Some(value.described_nodes), } } } @@ -181,7 +193,7 @@ pub struct SerializableGateway { // optional ip address in the case of host being a hostname that can't be resolved // (thank you wasm) #[cfg_attr(feature = "wasm-serde-types", tsify(optional))] - #[serde(alias = "explicit_ip")] + #[serde(alias = "explicit_ips")] pub explicit_ips: Option>, #[cfg_attr(feature = "wasm-serde-types", tsify(optional))] diff --git a/gateway/src/commands/run.rs b/gateway/src/commands/run.rs index 76f7629de5..244b637cac 100644 --- a/gateway/src/commands/run.rs +++ b/gateway/src/commands/run.rs @@ -58,6 +58,10 @@ pub struct Run { // the alias here is included for backwards compatibility (1.1.4 and before) nym_apis: Option>, + /// Path to .json file containing custom network specification. + #[arg(long, group = "network", hide = true)] + custom_mixnet: Option, + /// Comma separated list of endpoints of the validator #[arg( long, @@ -127,11 +131,6 @@ pub struct Run { )] medium_toggle: bool, - /// Path to .json file containing custom network specification. - /// Only usable when local network requester is enabled. - #[arg(long, group = "network", hide = true)] - custom_mixnet: Option, - /// Specifies whether this network requester will run using the default ExitPolicy /// as opposed to the allow list. /// Note: this setting will become the default in the future releases. @@ -249,12 +248,17 @@ pub async fn execute(args: Run) -> anyhow::Result<()> { } let node_details = node_details(&config)?; - let gateway = - crate::node::create_gateway(config, Some(nr_opts), Some(ip_opts), custom_mixnet).await?; + let mut gateway = + crate::node::create_gateway(config, Some(nr_opts), Some(ip_opts), custom_mixnet.clone()) + .await?; eprintln!( "\nTo bond your gateway you will need to install the Nym wallet, go to https://nymtech.net/get-involved and select the Download button.\n\ Select the correct version and install it to your machine. You will need to provide some of the following: \n "); output.to_stdout(&node_details); + if let Some(custom_mixnet) = custom_mixnet { + gateway = gateway.with_stored_topology(custom_mixnet)?; + } + gateway.run().await } diff --git a/gateway/src/error.rs b/gateway/src/error.rs index 61f75981ef..6e0b1e4456 100644 --- a/gateway/src/error.rs +++ b/gateway/src/error.rs @@ -39,6 +39,15 @@ pub(crate) enum GatewayError { source: io::Error, }, + #[error( + "failed to load custom topology using path '{}'. detailed message: {source}", file_path.display() + )] + CustomTopologyLoadFailure { + file_path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error( "failed to load config file for network requester (gateway-id: '{id}') using path '{}'. detailed message: {source}", path.display() )] diff --git a/gateway/src/node/mod.rs b/gateway/src/node/mod.rs index 2a491a1490..62565af6ed 100644 --- a/gateway/src/node/mod.rs +++ b/gateway/src/node/mod.rs @@ -32,6 +32,7 @@ use nym_node::wireguard::types::GatewayClientRegistry; use nym_statistics_common::collector::StatisticsSender; use nym_task::{TaskClient, TaskManager}; use nym_topology::provider_trait::TopologyProvider; +use nym_topology::HardcodedTopologyProvider; use nym_topology_control::accessor::TopologyAccessor; use nym_topology_control::nym_api_provider::NymApiTopologyProvider; use nym_topology_control::TopologyRefresher; @@ -42,7 +43,7 @@ use rand::seq::SliceRandom; use rand::thread_rng; use std::error::Error; use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use url::Url; @@ -138,6 +139,7 @@ pub(crate) struct Gateway { storage: St, client_registry: Arc, + custom_topology_provider: Option>, } impl Gateway { @@ -156,6 +158,7 @@ impl Gateway { network_requester_opts, ip_packet_router_opts, client_registry: Arc::new(DashMap::new()), + custom_topology_provider: None, }) } @@ -176,9 +179,22 @@ impl Gateway { sphinx_keypair: Arc::new(sphinx_keypair), storage, client_registry: Arc::new(DashMap::new()), + custom_topology_provider: None, } } + pub fn with_stored_topology>(mut self, file: P) -> Result { + self.custom_topology_provider = Some(Box::new( + HardcodedTopologyProvider::new_from_file(&file).map_err(|source| { + GatewayError::CustomTopologyLoadFailure { + file_path: file.as_ref().to_path_buf(), + source, + } + })?, + )); + Ok(self) + } + fn start_mix_socket_listener( &self, ack_sender: MixForwardingSender, @@ -448,12 +464,15 @@ impl Gateway { .map_err(Into::into) } - fn setup_topology_provider(nym_api_urls: Vec) -> Box { + fn setup_topology_provider( + custom_provider: Option>, + nym_api_urls: Vec, + ) -> Box { // if no custom provider was ... provided ..., create one using nym-api - Box::new(NymApiTopologyProvider::new( + custom_provider.unwrap_or(Box::new(NymApiTopologyProvider::new( nym_api_urls, env!("CARGO_PKG_VERSION").to_string(), - )) + ))) } // future responsible for periodically polling directory server and updating @@ -506,7 +525,7 @@ impl Gateway { })) } - pub async fn run(self) -> anyhow::Result<()> + pub async fn run(mut self) -> anyhow::Result<()> where St: Storage + Clone + 'static, { @@ -523,7 +542,10 @@ impl Gateway { CoconutVerifier::new(nyxd_client).await }?; - let topology_provider = Self::setup_topology_provider(self.config.get_nym_api_endpoints()); + let topology_provider = Self::setup_topology_provider( + self.custom_topology_provider.take(), + self.config.get_nym_api_endpoints(), + ); let shared_topology_access = TopologyAccessor::new(); diff --git a/mixnode/src/commands/run.rs b/mixnode/src/commands/run.rs index c4ab5125fb..bf51dc1778 100644 --- a/mixnode/src/commands/run.rs +++ b/mixnode/src/commands/run.rs @@ -11,6 +11,7 @@ use nym_bin_common::output_format::OutputFormat; use nym_config::helpers::SPECIAL_ADDRESSES; use nym_validator_client::nyxd; use std::net::IpAddr; +use std::path::PathBuf; #[derive(Args, Clone)] pub(crate) struct Run { /// Id of the nym-mixnode we want to run @@ -42,6 +43,10 @@ pub(crate) struct Run { #[clap(long, alias = "validators", value_delimiter = ',')] nym_apis: Option>, + /// Path to .json file containing custom network specification. + #[arg(long, group = "network", hide = true)] + pub custom_mixnet: Option, + #[clap(short, long, default_value_t = OutputFormat::default())] output: OutputFormat, } @@ -91,6 +96,10 @@ pub(crate) async fn execute(args: &Run) -> anyhow::Result<()> { Select the correct version and install it to your machine. You will need to provide the following: \n "); mixnode.print_node_details(args.output); + if let Some(custom_mixnet) = &args.custom_mixnet { + mixnode = mixnode.with_stored_topology(custom_mixnet)?; + } + mixnode.run().await?; Ok(()) } diff --git a/mixnode/src/error.rs b/mixnode/src/error.rs index 57c4594747..34c57b1f0a 100644 --- a/mixnode/src/error.rs +++ b/mixnode/src/error.rs @@ -34,6 +34,15 @@ pub enum MixnodeError { source: io::Error, }, + #[error( + "failed to load custom topology using path '{}'. detailed message: {source}", file_path.display() + )] + CustomTopologyLoadFailure { + file_path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error( "failed to save config file for id {id} using path '{}'. detailed message: {source}", path.display() )] diff --git a/mixnode/src/node/mod.rs b/mixnode/src/node/mod.rs index 541954c424..587e1c61c2 100644 --- a/mixnode/src/node/mod.rs +++ b/mixnode/src/node/mod.rs @@ -19,6 +19,7 @@ use nym_crypto::asymmetric::{encryption, identity}; use nym_mixnode_common::verloc::{self, AtomicVerlocResult, VerlocMeasurer}; use nym_task::{TaskClient, TaskManager}; use nym_topology::provider_trait::TopologyProvider; +use nym_topology::HardcodedTopologyProvider; use nym_topology_control::accessor::TopologyAccessor; use nym_topology_control::nym_api_provider::NymApiTopologyProvider; use nym_topology_control::TopologyRefresher; @@ -27,6 +28,7 @@ use nym_validator_client::NymApiClient; use rand::seq::SliceRandom; use rand::thread_rng; use std::net::SocketAddr; +use std::path::Path; use std::process; use std::sync::Arc; use url::Url; @@ -44,6 +46,7 @@ pub struct MixNode { descriptor: NodeDescription, identity_keypair: Arc, sphinx_keypair: Arc, + custom_topology_provider: Option>, } impl MixNode { @@ -53,6 +56,7 @@ impl MixNode { identity_keypair: Arc::new(load_identity_keys(&config)?), sphinx_keypair: Arc::new(load_sphinx_keys(&config)?), config, + custom_topology_provider: None, }) } @@ -75,6 +79,18 @@ impl MixNode { println!("{}", output.format(&node_details)); } + pub fn with_stored_topology>(mut self, file: P) -> Result { + self.custom_topology_provider = Some(Box::new( + HardcodedTopologyProvider::new_from_file(&file).map_err(|source| { + MixnodeError::CustomTopologyLoadFailure { + file_path: file.as_ref().to_path_buf(), + source, + } + })?, + )); + Ok(self) + } + fn start_http_api( &self, atomic_verloc_result: AtomicVerlocResult, @@ -207,12 +223,15 @@ impl MixNode { atomic_verloc_results } - fn setup_topology_provider(nym_api_urls: Vec) -> Box { + fn setup_topology_provider( + custom_provider: Option>, + nym_api_urls: Vec, + ) -> Box { // if no custom provider was ... provided ..., create one using nym-api - Box::new(NymApiTopologyProvider::new( + custom_provider.unwrap_or(Box::new(NymApiTopologyProvider::new( nym_api_urls, env!("CARGO_PKG_VERSION").to_string(), - )) + ))) } // future responsible for periodically polling directory server and updating @@ -295,7 +314,10 @@ impl MixNode { let (node_stats_pointer, node_stats_update_sender) = self .start_node_stats_controller(shutdown.subscribe().named("node_statistics::Controller")); - let topology_provider = Self::setup_topology_provider(self.config.get_nym_api_endpoints()); + let topology_provider = Self::setup_topology_provider( + self.custom_topology_provider.take(), + self.config.get_nym_api_endpoints(), + ); let shared_topology_access = TopologyAccessor::new(); Self::start_topology_refresher( topology_provider, diff --git a/scripts/build_topology.py b/scripts/build_topology.py index 14ec7b7d54..a808620cc3 100644 --- a/scripts/build_topology.py +++ b/scripts/build_topology.py @@ -14,6 +14,18 @@ def add_mixnode(base_network, base_dir, mix_id): base_network["mixnodes"][str(mix_id)][0]["layer"] = mix_id base_network["mixnodes"][str(mix_id)][0]["mix_id"] = mix_id base_network["mixnodes"][str(mix_id)][0]["owner"] = "whatever" + + #described_node + template = mixnode_template() + template["Mixnode"]["bond"]["mix_node"]["identity_key"] = mix_data["identity_key"] + template["Mixnode"]["bond"]["mix_node"]["sphinx_key"] = mix_data["sphinx_key"] + template["Mixnode"]["bond"]["mix_node"]["mix_port"] = mix_data["mix_port"] + template["Mixnode"]["bond"]["mix_node"]["host"] = mix_data["bind_address"] + template["Mixnode"]["bond"]["layer"] = mix_id + template["Mixnode"]["bond"]["mix_id"] = mix_id + template["Mixnode"]["self_described"]["host_information"]["keys"]["ed25519"] = mix_data["identity_key"] + template["Mixnode"]["self_described"]["host_information"]["keys"]["x25519"] = mix_data["sphinx_key"] + base_network["described_nodes"][mix_id] = template return base_network @@ -27,6 +39,17 @@ def add_gateway(base_network, base_dir): # base_network["gateways"][0]["version"] = gateway_data["version"] base_network["gateways"][0]["host"] = gateway_data["bind_address"] base_network["gateways"][0]["owner"] = "whatever" + + #described_node + template = gateway_template() + template["Gateway"]["bond"]["gateway"]["identity_key"] = gateway_data["identity_key"] + template["Gateway"]["bond"]["gateway"]["sphinx_key"] = gateway_data["sphinx_key"] + template["Gateway"]["bond"]["gateway"]["mix_port"] = gateway_data["mix_port"] + template["Gateway"]["bond"]["gateway"]["clients_port"] = gateway_data["clients_port"] + template["Gateway"]["bond"]["gateway"]["host"] = gateway_data["bind_address"] + template["Gateway"]["self_described"]["host_information"]["keys"]["ed25519"] = gateway_data["identity_key"] + template["Gateway"]["self_described"]["host_information"]["keys"]["x25519"] = gateway_data["sphinx_key"] + base_network["described_nodes"][0] = template return base_network @@ -37,7 +60,8 @@ def main(args): "2": [{}], "3": [{}], }, - "gateways": [{}] + "gateways": [{}], + "described_nodes":[{}, {}, {}, {}] } base_dir = args[0] @@ -50,5 +74,117 @@ def main(args): json.dump(base_network, out, indent=2) +def gateway_template(): + return {"Gateway": { + "bond": { + "pledge_amount": { + "denom": "unym", + "amount": "0" + }, + "owner": "whatever", + "block_height": 0, + "gateway": { + "host": "TO_BE_FILLED", + "mix_port": "TO_BE_FILLED", + "clients_port": "TO_BE_FILLED", + "location": "whatever", + "sphinx_key": "TO_BE_FILLED", + "identity_key": "TO_BE_FILLED", + "version": "whatever", + }, + "proxy": None, + }, + "self_described": { + "host_information": { + "ip_address": [ + "0.0.0.0" + ], + "hostname": None, + "keys": { + "ed25519": "TO_BE_FILLED", + "x25519": "TO_BE_FILLED" + } + }, + "build_information": { + "binary_name": "whatever", + "build_timestamp": "whatever", + "build_version": "whatever", + "commit_sha": "whatever", + "commit_timestamp": "whatever", + "commit_branch": "whatever", + "rustc_version": "whatever", + "rustc_channel": "whatever", + "cargo_profile": "whatever" + }, + "network_requester": { + "address": "none", + "uses_exit_policy": True + }, + "mixnet_websockets": { + "ws_port": 9000, + "wss_port": None + }, + "noise_information": { + "supported": True + } + } + }} + +def mixnode_template(): + return { + "Mixnode": { + "bond": { + "mix_id": "TO_BE_FILLED", + "owner": "whatever", + "original_pledge": { + "denom": "unym", + "amount": "0" + }, + "layer": "TO_BE_FILLED", + "mix_node": { + "host": "TO_BE_FILLED", + "mix_port": "TO_BE_FILLED", + "verloc_port": 1790, + "http_api_port": 8000, + "sphinx_key": "TO_BE_FILLED", + "identity_key": "TO_BE_FILLED", + "version": "whatever" + }, + "proxy": None, + "bonding_height": 0, + "is_unbonding": False + }, + "self_described": { + "host_information": { + "ip_address": [ + "0.0.0.0" + ], + "hostname": None, + "keys": { + "ed25519": "TO_BE_FILLED", + "x25519": "TO_BE_FILLED" + } + }, + "build_information": { + "binary_name": "whatever", + "build_timestamp": "whatever", + "build_version": "whatever", + "commit_sha": "whatever", + "commit_timestamp": "whatever", + "commit_branch": "whatever", + "rustc_version": "whatever", + "rustc_channel": "whatever", + "cargo_profile": "whatever" + }, + "network_requester": None, + "ip_packet_router": None, + "mixnet_websockets": None, + "noise_information": { + "supported": True + } + } + } + } + if __name__ == '__main__': main(sys.argv[1:]) diff --git a/scripts/localnet_start.sh b/scripts/localnet_start.sh index 1a821c31e1..679edd7fd5 100755 --- a/scripts/localnet_start.sh +++ b/scripts/localnet_start.sh @@ -42,10 +42,10 @@ echo "the full network file is located at $networkfile" echo "starting the mixnet..." tmux start-server -tmux new-session -d -s localnet -n Mixnet -d "/usr/bin/env sh -c \" cargo run --release --bin nym-mixnode -- run --id mix1-$suffix \"" -tmux split-window -t localnet:0 "/usr/bin/env sh -c \" cargo run --release --bin nym-mixnode -- run --id mix2-$suffix \"" -tmux split-window -t localnet:0 "/usr/bin/env sh -c \" cargo run --release --bin nym-mixnode -- run --id mix3-$suffix \"" -tmux split-window -t localnet:0 "/usr/bin/env sh -c \" cargo run --release --bin nym-gateway -- run --id gateway-$suffix --local \"" +tmux new-session -d -s localnet -n Mixnet -d "/usr/bin/env sh -c \" cargo run --release --bin nym-mixnode -- run --id mix1-$suffix --custom-mixnet \"$networkfile\" \"" +tmux split-window -t localnet:0 "/usr/bin/env sh -c \" cargo run --release --bin nym-mixnode -- run --id mix2-$suffix --custom-mixnet \"$networkfile\" \"" +tmux split-window -t localnet:0 "/usr/bin/env sh -c \" cargo run --release --bin nym-mixnode -- run --id mix3-$suffix --custom-mixnet \"$networkfile\" \"" +tmux split-window -t localnet:0 "/usr/bin/env sh -c \" cargo run --release --bin nym-gateway -- run --id gateway-$suffix --local --custom-mixnet \"$networkfile\" \"" while ! nc -z localhost 9000; do echo "waiting for nym-gateway to launch on port 9000..."