diff --git a/.github/workflows/ci-check-ns-api-version.yml b/.github/workflows/ci-check-ns-api-version.yml index f4497507649..e767c261128 100644 --- a/.github/workflows/ci-check-ns-api-version.yml +++ b/.github/workflows/ci-check-ns-api-version.yml @@ -3,7 +3,7 @@ name: ci-check-ns-api-version on: pull_request: paths: - - "nym-node-status-api/**" + - "nym-node-status-api/nym-node-status-api/**" env: WORKING_DIRECTORY: "nym-node-status-api/nym-node-status-api" diff --git a/Cargo.lock b/Cargo.lock index f70dc814233..37601d69489 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5970,6 +5970,7 @@ dependencies = [ "nym-validator-client", "pnet_packet", "rand 0.8.5", + "reqwest 0.12.22", "serde", "serde_json", "thiserror 2.0.17", @@ -6586,7 +6587,7 @@ dependencies = [ [[package]] name = "nym-node-status-agent" -version = "1.0.7" +version = "1.0.8-gw-socks5" dependencies = [ "anyhow", "clap", diff --git a/nym-gateway-probe/Cargo.toml b/nym-gateway-probe/Cargo.toml index 5c91e350782..41c76f606c5 100644 --- a/nym-gateway-probe/Cargo.toml +++ b/nym-gateway-probe/Cargo.toml @@ -23,6 +23,7 @@ hex.workspace = true tracing.workspace = true pnet_packet.workspace = true rand.workspace = true +reqwest = { workspace = true, features = ["socks"] } serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index 5f7827dff01..8dea20eeade 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -7,7 +7,10 @@ use std::{ time::Duration, }; -use crate::{netstack::NetstackResult, types::Entry}; +use crate::{ + netstack::NetstackResult, + types::{Entry, HttpsConnectivityTest}, +}; use anyhow::bail; use base64::{Engine as _, engine::general_purpose}; use bytes::BytesMut; @@ -36,8 +39,9 @@ use nym_ip_packet_requests::{ }; use nym_sdk::mixnet::{ CredentialStorage, Ephemeral, KeyStore, MixnetClient, MixnetClientBuilder, MixnetClientStorage, - NodeIdentity, Recipient, ReconstructedMessage, StoragePaths, + NodeIdentity, Recipient, ReconstructedMessage, Socks5, StoragePaths, }; +use nym_validator_client::models::NetworkRequesterDetails; use rand::rngs::OsRng; use std::path::PathBuf; @@ -48,7 +52,7 @@ use url::Url; use crate::{ icmp::{check_for_icmp_beacon_reply, icmp_identifier, send_ping_v4, send_ping_v6}, - types::Exit, + types::{Exit, HttpsConnectivityResult, Socks5ProbeResults}, }; use netstack::{NetstackRequest, NetstackRequestGo}; @@ -124,6 +128,15 @@ impl CredentialArgs { } } +#[derive(Args)] +pub struct Socks5Args { + #[arg(long, default_value_t = 45)] + mixnet_client_timeout_sec: u64, + + #[arg(long, default_value_t = 10)] + test_count: u64, +} + #[derive(Default, Debug)] pub enum TestedNode { #[default] @@ -151,6 +164,7 @@ impl TestedNode { pub struct TestedNodeDetails { identity: NodeIdentity, exit_router_address: Option, + network_requester_details: Option, authenticator_address: Option, authenticator_version: AuthenticatorVersion, ip_address: Option, @@ -162,6 +176,7 @@ pub struct Probe { amnezia_args: String, netstack_args: NetstackArgs, credentials_args: CredentialArgs, + socks5_args: Socks5Args, } impl Probe { @@ -170,6 +185,7 @@ impl Probe { tested_node: TestedNode, netstack_args: NetstackArgs, credentials_args: CredentialArgs, + socks5_args: Socks5Args, ) -> Self { Self { entrypoint, @@ -177,6 +193,7 @@ impl Probe { amnezia_args: "".into(), netstack_args, credentials_args, + socks5_args, } } pub fn with_amnezia(&mut self, args: &str) -> &Self { @@ -184,6 +201,58 @@ impl Probe { self } + pub async fn test_socks5_only( + self, + directory: NymApiDirectory, + gateway_key: Option, + network_details: NymNetworkDetails, + ) -> anyhow::Result { + let exit_gateway = match gateway_key { + Some(gateway_key) => NodeIdentity::from_base58_string(gateway_key)?, + None => directory.random_exit_with_nr()?, + }; + info!("Testing SOCKS5 only on exit gateway {}", exit_gateway); + let node_info = directory + .exit_gateway_nr(&exit_gateway)? + .to_testable_node()?; + + let socks5_outcome = { + if let Some(ref nr_details) = node_info.network_requester_details { + match do_socks5_connectivity_test( + &nr_details.address, + network_details, + directory, + self.socks5_args.mixnet_client_timeout_sec, + self.socks5_args.test_count, + ) + .await + { + Ok(results) => Some(results), + Err(e) => { + error!("SOCKS5 test failed: {}", e); + None + } + } + } else { + info!("No NR available, skipping SOCKS5 tests"); + None + } + }; + + let probe_result = ProbeResult { + node: exit_gateway.to_base58_string(), + used_entry: exit_gateway.to_base58_string(), + outcome: ProbeOutcome { + as_entry: Entry::NotTested, + as_exit: None, + socks5: socks5_outcome, + wg: None, + }, + }; + + Ok(probe_result) + } + pub async fn probe( self, directory: NymApiDirectory, @@ -225,6 +294,7 @@ impl Probe { nyxd_url, tested_entry, only_wireguard, + directory, ) .await } @@ -305,6 +375,7 @@ impl Probe { nyxd_url, tested_entry, only_wireguard, + directory, ) .await } @@ -361,6 +432,7 @@ impl Probe { nyxd_url: Url, tested_entry: bool, only_wireguard: bool, + directory: NymApiDirectory, ) -> anyhow::Result where T: MixnetClientStorage + Clone + 'static, @@ -381,6 +453,7 @@ impl Probe { Entry::EntryFailure }, as_exit: None, + socks5: None, wg: None, }, }); @@ -403,6 +476,7 @@ impl Probe { Entry::NotTested }, as_exit: None, + socks5: None, wg: None, }), mixnet_client, @@ -475,9 +549,34 @@ impl Probe { WgProbeResults::default() }; + // test failure doesn't stop further tests + let socks5_outcome = { + if let Some(ref nr_details) = node_info.network_requester_details { + match do_socks5_connectivity_test( + &nr_details.address, + NymNetworkDetails::new_from_env(), + directory, + self.socks5_args.mixnet_client_timeout_sec, + self.socks5_args.test_count, + ) + .await + { + Ok(results) => Some(results), + Err(e) => { + error!("SOCKS5 test failed: {}", e); + None + } + } + } else { + info!("No NR available, skipping SOCKS5 tests"); + None + } + }; + // Disconnect the mixnet client gracefully outcome.map(|mut outcome| { outcome.wg = Some(wg_outcome); + outcome.socks5 = socks5_outcome; ProbeResult { node: node_info.identity.to_string(), used_entry: mixnet_entry_gateway_id.to_string(), @@ -711,6 +810,7 @@ async fn do_ping( exit_result.map(|exit| ProbeOutcome { as_entry: entry, as_exit: exit, + socks5: None, wg: None, }), mixnet_client, @@ -775,6 +875,109 @@ async fn do_ping_exit( listen_for_icmp_ping_replies(mixnet_client, our_ips).await } +/// Creates a SOCKS5 proxy connection through the mixnet to the exit GW +/// and performs necessary tests. +#[instrument(level = "info", name = "socks5_test", skip_all)] +async fn do_socks5_connectivity_test( + network_requester_address: &str, + network_details: NymNetworkDetails, + directory: NymApiDirectory, + mixnet_client_timeout: u64, + test_run_count: u64, +) -> anyhow::Result { + info!( + "Starting SOCKS5 test through Network Requester: {}", + network_requester_address + ); + + let mut results = Socks5ProbeResults::default(); + + // parse the network requester address + let nr_recipient = match network_requester_address.parse::() { + Ok(addr) => addr, + Err(e) => { + error!("Invalid Network Requester address: {}", e); + results.https_connectivity = + HttpsConnectivityResult::with_error(format!("Invalid NR address: {}", e)); + return Ok(results); + } + }; + + info!( + "Network Requester gateway: {}", + nr_recipient.gateway().to_base58_string() + ); + info!( + "Network Requester identity: {}", + nr_recipient.identity().to_base58_string() + ); + + // create ephemeral SOCKS5 client + let socks5_config = Socks5::new(network_requester_address.to_string()); + + // Create debug config similar to main probe + let mut debug_config = nym_client_core::config::DebugConfig::default(); + debug_config + .traffic + .disable_main_poisson_packet_distribution = true; + debug_config.cover_traffic.disable_loop_cover_traffic_stream = true; + debug_config.topology.ignore_egress_epoch_role = true; + // since we define both entry & exit gateways to be the same tested GW, + // this shouldn't negatively affect mixnet layers but it will force route + // construction in case GW would get filtered out on topology refresh + debug_config.topology.minimum_gateway_performance = 0; + + // Verify the NR gateway exists in the directory with exit_nr role + let nr_gateway_id = nr_recipient.gateway(); + if let Err(e) = directory.exit_gateway_nr(&nr_gateway_id) { + results.https_connectivity = HttpsConnectivityResult::with_error(e.to_string()); + return Ok(results); + } else { + info!("✔️ Network Requester gateway found in directory with exit_nr role"); + } + + // use intended exit as entry as well + let entry_gateway = nr_gateway_id; + + let socks5_client_builder = MixnetClientBuilder::new_ephemeral() + // Specify entry gateway explicitly + .request_gateway(entry_gateway.to_base58_string()) + .socks5_config(socks5_config) + .network_details(network_details) + .debug_config(debug_config) + .build()?; + + // connect to mixnet via SOCKS5 + let socks5_client = match socks5_client_builder.connect_to_mixnet_via_socks5().await { + Ok(client) => { + info!("Successfully established SOCKS5 proxy connection"); + info!( + "Connected via entry gateway: {}", + client.nym_address().gateway().to_base58_string() + ); + results.can_connect_socks5 = true; + client + } + Err(e) => { + error!("Failed to establish SOCKS5 connection: {}", e); + results.https_connectivity = + HttpsConnectivityResult::with_error(format!("SOCKS5 connection failed: {}", e)); + return Ok(results); + } + }; + + info!("Waiting for network topology to be ready..."); + tokio::time::sleep(Duration::from_secs(10)).await; + + let test = HttpsConnectivityTest::new(test_run_count, mixnet_client_timeout); + results.https_connectivity = test.run_tests(socks5_client.socks5_url()).await; + + // cleanup + socks5_client.disconnect().await; + + Ok(results) +} + async fn send_icmp_pings( mixnet_client: &MixnetClient, our_ips: IpPair, diff --git a/nym-gateway-probe/src/nodes.rs b/nym-gateway-probe/src/nodes.rs index 773470859f9..12a8c051158 100644 --- a/nym-gateway-probe/src/nodes.rs +++ b/nym-gateway-probe/src/nodes.rs @@ -118,9 +118,12 @@ impl DirectoryNode { .first() .copied(); + let network_requester_details = self.described.description.network_requester.clone(); + Ok(TestedNodeDetails { identity: self.identity(), exit_router_address, + network_requester_details, authenticator_address, authenticator_version, ip_address, @@ -199,6 +202,16 @@ impl NymApiDirectory { .map(|(id, _)| *id) } + pub fn random_exit_with_nr(&self) -> anyhow::Result { + info!("Selecting random gateway with NR enabled"); + self.nodes + .iter() + .filter(|(_, n)| n.described.description.ip_packet_router.is_some()) + .choose(&mut rand::thread_rng()) + .ok_or(anyhow!("no gateways running NR available")) + .map(|(id, _)| *id) + } + pub fn random_entry_gateway(&self) -> anyhow::Result { info!("Selecting random entry gateway"); self.nodes @@ -225,4 +238,14 @@ impl NymApiDirectory { }; Ok(maybe_entry) } + + pub fn exit_gateway_nr(&self, identity: &NodeIdentity) -> anyhow::Result { + let Some(maybe_entry) = self.nodes.get(identity).cloned() else { + bail!("{identity} not found in directory") + }; + if !maybe_entry.described.description.declared_role.exit_nr { + bail!("{identity} doesn't support exit NR mode") + }; + Ok(maybe_entry) + } } diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index 9c715bcdda1..01b784ef026 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -5,7 +5,8 @@ use clap::{Parser, Subcommand}; use nym_bin_common::bin_info; use nym_config::defaults::setup_env; use nym_gateway_probe::nodes::NymApiDirectory; -use nym_gateway_probe::{CredentialArgs, NetstackArgs, ProbeResult, TestedNode}; +use nym_gateway_probe::{CredentialArgs, NetstackArgs, ProbeResult, Socks5Args, TestedNode}; +use nym_sdk::NymNetworkDetails; use nym_sdk::mixnet::NodeIdentity; use std::path::Path; use std::{path::PathBuf, sync::OnceLock}; @@ -68,6 +69,10 @@ struct CliArgs { /// Arguments to manage credentials #[command(flatten)] credential_args: CredentialArgs, + + /// Arguments to configure socks5 probe + #[command(flatten)] + socks5_args: Socks5Args, } const DEFAULT_CONFIG_DIR: &str = "/tmp/nym-gateway-probe/config/"; @@ -83,6 +88,11 @@ enum Commands { #[arg(long)] config_dir: Option, }, + Socks5 { + /// if not provided, test a random gateway + #[arg(long)] + gateway_key: Option, + }, } fn setup_logging() { @@ -151,13 +161,18 @@ pub(crate) async fn run() -> anyhow::Result { (None, _) => TestedNode::SameAsEntry, }; - let mut trial = - nym_gateway_probe::Probe::new(entry, test_point, args.netstack_args, args.credential_args); + let mut trial = nym_gateway_probe::Probe::new( + entry, + test_point, + args.netstack_args, + args.credential_args, + args.socks5_args, + ); if let Some(awg_args) = args.amnezia_args { trial.with_amnezia(&awg_args); } - match &args.command { + match args.command { Some(Commands::RunLocal { mnemonic, config_dir, @@ -173,7 +188,7 @@ pub(crate) async fn run() -> anyhow::Result { Box::pin(trial.probe_run_locally( &config_dir, - mnemonic, + &mnemonic, directory, nyxd_url, args.ignore_egress_epoch_role, @@ -182,6 +197,10 @@ pub(crate) async fn run() -> anyhow::Result { )) .await } + Some(Commands::Socks5 { gateway_key }) => { + let network_details = NymNetworkDetails::new_from_env(); + Box::pin(trial.test_socks5_only(directory, gateway_key, network_details)).await + } None => { Box::pin(trial.probe( directory, diff --git a/nym-gateway-probe/src/types.rs b/nym-gateway-probe/src/types.rs index 17f02b40f8a..cea480586b2 100644 --- a/nym-gateway-probe/src/types.rs +++ b/nym-gateway-probe/src/types.rs @@ -1,5 +1,8 @@ +use std::time::Duration; + use nym_connection_monitor::ConnectionStatusEvent; use serde::{Deserialize, Serialize}; +use tracing::{debug, info, warn}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProbeResult { @@ -12,6 +15,7 @@ pub struct ProbeResult { pub struct ProbeOutcome { pub as_entry: Entry, pub as_exit: Option, + pub socks5: Option, pub wg: Option, } @@ -122,6 +126,169 @@ impl Exit { } } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Socks5ProbeResults { + /// whether we could establish a SOCKS5 proxy connection + pub can_connect_socks5: bool, + + /// HTTPS connectivity test + pub https_connectivity: HttpsConnectivityResult, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct HttpsConnectivityResult { + /// successfully completed HTTPS request + https_success: bool, + + /// HTTPS status code received + https_status_code: Option, + + /// average HTTPS request latency in milliseconds + https_latency_ms: Option, + + /// error message(s) (if any) + error: Option, +} + +impl HttpsConnectivityResult { + pub fn with_error(error: impl Into) -> Self { + Self { + https_success: false, + https_status_code: None, + https_latency_ms: None, + error: Some(error.into()), + } + } +} + +pub struct HttpsConnectivityTest { + test_count: u64, + mixnet_client_timeout: Duration, +} + +/// endpoint to test against +/// https://www.quicknode.com/docs/ethereum/web3_clientVersion +const TARGET_URL: &str = "https://docs-demo.quiknode.pro"; +const POST_BODY: &str = r#"{"jsonrpc":"2.0","method":"web3_clientVersion","params":[],"id":1}"#; + +impl HttpsConnectivityTest { + pub fn new(test_count: u64, mixnet_client_timeout: u64) -> Self { + Self { + test_count: std::cmp::max(test_count, 1), + mixnet_client_timeout: Duration::from_secs(mixnet_client_timeout), + } + } + + pub async fn run_tests(self, socks5_url: String) -> HttpsConnectivityResult { + let mut result = HttpsConnectivityResult::default(); + + let proxy = match reqwest::Proxy::all(socks5_url) { + Ok(p) => p, + Err(e) => { + result.error = Some(format!("Failed to create proxy: {}", e)); + return result; + } + }; + + let client = match reqwest::Client::builder() + .proxy(proxy) + .timeout(self.mixnet_client_timeout) + .build() + { + Ok(c) => c, + Err(e) => { + result.error = Some(format!("Failed to build HTTP client: {}", e)); + return result; + } + }; + + let mut successful_runs = 0u64; + for i in 1..self.test_count + 1 { + info!("Running test {}/{}", i, self.test_count); + let interim_res = self.perform_https_request(&client).await; + if interim_res.https_success + && let Some(latency_ms) = interim_res.https_latency_ms + { + successful_runs += 1; + result.https_latency_ms = Some( + result + .https_latency_ms + .map_or(latency_ms, |existing| existing + latency_ms), + ); + result.https_success = true; + result.https_status_code = interim_res.https_status_code; + info!("{}/{} latency: {}ms", i, self.test_count, latency_ms); + } else if let Some(new_error) = interim_res.error { + result.error = Some(result.error.map_or(new_error.clone(), |existing| { + format!("{},{}", existing, new_error) + })) + } + + // too many failed runs: return early + if successful_runs < 2 && i - successful_runs > 2 { + // if < 2 runs, we don't have to calculate average before returning + return result; + } + } + result.https_latency_ms = result + .https_latency_ms + .map(|latency| latency / successful_runs); + info!( + "AVG latency over {} runs (in ms): {:?}", + successful_runs, result.https_latency_ms + ); + + result + } + + async fn perform_https_request(&self, client: &reqwest::Client) -> HttpsConnectivityResult { + use tokio::time::Instant; + + let mut result = HttpsConnectivityResult::default(); + let start = Instant::now(); + match tokio::time::timeout( + self.mixnet_client_timeout, + client + .post(TARGET_URL) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(POST_BODY) + .send(), + ) + .await + { + Ok(Ok(response)) => { + let elapsed = start.elapsed(); + let status = response.status(); + result.https_success = status.is_success(); + result.https_status_code = Some(status.as_u16()); + result.https_latency_ms = Some(elapsed.as_millis() as u64); + debug!( + "HTTPS test completed: status={}, latency={}ms", + status.as_u16(), + elapsed.as_millis() + ); + } + Ok(Err(e)) => { + warn!("HTTPS request failed: {}", e); + if result.error.is_none() { + result.error = Some(format!("HTTPS request failed: {}", e)); + } + } + Err(_) => { + warn!( + "HTTPS request timed out after {}s", + self.mixnet_client_timeout.as_secs() + ); + if result.error.is_none() { + result.error = Some("HTTPS request timed out".to_string()); + } + } + } + + result + } +} + #[derive(Debug, Clone, Default)] pub struct IpPingReplies { pub ipr_tun_ip_v4: bool, diff --git a/nym-node-status-api/nym-node-status-agent/Cargo.toml b/nym-node-status-api/nym-node-status-agent/Cargo.toml index b7840cd9a6c..b7a961424bf 100644 --- a/nym-node-status-api/nym-node-status-agent/Cargo.toml +++ b/nym-node-status-api/nym-node-status-agent/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-node-status-agent" -version = "1.0.7" +version = "1.0.8-gw-socks5" authors.workspace = true repository.workspace = true homepage.workspace = true diff --git a/nym-node-status-api/nym-node-status-agent/run.sh b/nym-node-status-api/nym-node-status-agent/run.sh index 1061ad5f95c..2a5a0b5e411 100755 --- a/nym-node-status-api/nym-node-status-agent/run.sh +++ b/nym-node-status-api/nym-node-status-agent/run.sh @@ -1,15 +1,16 @@ #!/bin/bash +# used primarily for local testing + set -eu export ENVIRONMENT=${ENVIRONMENT:-"mainnet"} -probe_git_ref="nym-vpn-core-v1.4.0" - crate_root=$(dirname $(realpath "$0")) +echo crate_root=${crate_root} monorepo_root=$(realpath "${crate_root}/../..") +echo monorepo_root=${monorepo_root} -echo "Expecting nym-vpn-client repo at a sibling level of nym monorepo dir" -gateway_probe_src=$(dirname "${monorepo_root}")/nym-vpn-client/nym-vpn-core +gateway_probe_src="${monorepo_root}/nym-gateway-probe" echo "gateway_probe_src=$gateway_probe_src" set -a @@ -25,7 +26,8 @@ export RUST_LOG="info" NODE_STATUS_AGENT_SERVER_ADDRESS="http://127.0.0.1" NODE_STATUS_AGENT_SERVER_PORT="8000" SERVER="${NODE_STATUS_AGENT_SERVER_ADDRESS}|${NODE_STATUS_AGENT_SERVER_PORT}" -export NODE_STATUS_AGENT_AUTH_KEY="BjyC9SsHAZUzPRkQR4sPTvVrp4GgaquTh5YfSJksvvWT" +# hardcoded key used only for LOCAL TESTING +export NODE_STATUS_AGENT_AUTH_KEY=${NODE_STATUS_AGENT_AUTH_KEY_STAGING:-"BjyC9SsHAZUzPRkQR4sPTvVrp4GgaquTh5YfSJksvvWT"} export NODE_STATUS_AGENT_PROBE_PATH="$crate_root/nym-gateway-probe" export NODE_STATUS_AGENT_PROBE_EXTRA_ARGS="netstack-download-timeout-sec=30,netstack-num-ping=2,netstack-send-timeout-sec=1,netstack-recv-timeout-sec=1" @@ -35,11 +37,9 @@ echo "Running $workers workers in parallel" # build & copy over GW probe function copy_gw_probe() { pushd $gateway_probe_src - git fetch -a - git checkout $probe_git_ref cargo build --release --package nym-gateway-probe - cp target/release/nym-gateway-probe "$crate_root" + cp "${monorepo_root}/target/release/nym-gateway-probe" "$crate_root" $crate_root/nym-gateway-probe --version popd diff --git a/sdk/rust/nym-sdk/src/mixnet/client.rs b/sdk/rust/nym-sdk/src/mixnet/client.rs index 5020c1bd5c8..7fe176b05bd 100644 --- a/sdk/rust/nym-sdk/src/mixnet/client.rs +++ b/sdk/rust/nym-sdk/src/mixnet/client.rs @@ -8,7 +8,7 @@ use crate::mixnet::{CredentialStorage, MixnetClient, Recipient}; use crate::GatewayTransceiver; use crate::NymNetworkDetails; use crate::{Error, Result}; -use log::{debug, warn}; +use log::{debug, info, warn}; use nym_client_core::client::base_client::storage::gateways_storage::GatewayRegistration; use nym_client_core::client::base_client::storage::helpers::{ get_active_gateway_identity, get_all_registered_identities, has_gateway_details, @@ -601,6 +601,10 @@ where ); let available_gateways = self.available_gateways().await?; + info!("Listing all available gateways in topology:"); + for node in available_gateways.iter() { + info!("{}", node.identity_key.to_base58_string()); + } Ok(GatewaySetup::New { specification: selection_spec,