From cffed7182fdf6c562bc5fe3a9481753324ebc98a Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Sun, 22 Mar 2026 16:09:13 -0400 Subject: [PATCH] client: get client IP from daemon in disconnect command Match the connect command's behavior by getting the client IP from the daemon instead of discovering it locally. This avoids mismatches when the daemon has a different view of the public IP (e.g. behind NAT). Extract resolve_client_ip() into a testable method and add unit tests for valid IP, empty IP, invalid IP, and daemon-unreachable cases. Move now-unused look_for_ip helpers behind #[cfg(test)]. Remove deprecated --client-ip from connect/disconnect invocations in e2e tests. --- CHANGELOG.md | 3 + client/doublezero/src/command/connect.rs | 14 +- client/doublezero/src/command/disconnect.rs | 79 ++++- client/doublezero/src/command/helpers.rs | 330 ++------------------ e2e/allocation_lifecycle_test.go | 6 +- e2e/ibrl_multicast_coexistence_test.go | 46 +-- e2e/main_test.go | 18 +- e2e/multi_tunnel_endpoint_test.go | 20 +- e2e/user_limits_test.go | 12 +- 9 files changed, 146 insertions(+), 382 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dca34408d3..7aff8a72b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file. ### Changes +- Client + - Get client IP from the daemon in the disconnect command, matching the connect command's behavior, to avoid IP mismatches behind NAT + ## [v0.13.0](https://github.com/malbeclabs/doublezero/compare/client/v0.12.0...client/v0.13.0) - 2026-03-20 ### Breaking diff --git a/client/doublezero/src/command/connect.rs b/client/doublezero/src/command/connect.rs index 2f91b0708e..b284b1ff31 100644 --- a/client/doublezero/src/command/connect.rs +++ b/client/doublezero/src/command/connect.rs @@ -126,19 +126,7 @@ impl ProvisioningCliCommand { } // Get public IP from daemon - let v2_status = controller.v2_status().await?; - if v2_status.client_ip.is_empty() { - return Err(eyre::eyre!( - "Daemon has not discovered its client IP. Ensure the daemon is running \ - and has started up successfully, or set --client-ip on the daemon." - )); - } - let client_ip: Ipv4Addr = v2_status.client_ip.parse().map_err(|e| { - eyre::eyre!( - "Daemon returned invalid client IP '{}': {e}", - v2_status.client_ip - ) - })?; + let client_ip = super::helpers::resolve_client_ip(controller).await?; let client_ip_str = client_ip.to_string(); if !check_accesspass(client, client_ip)? { diff --git a/client/doublezero/src/command/disconnect.rs b/client/doublezero/src/command/disconnect.rs index f8a2b905ed..4c6e7d7459 100644 --- a/client/doublezero/src/command/disconnect.rs +++ b/client/doublezero/src/command/disconnect.rs @@ -20,8 +20,6 @@ use doublezero_sdk::{ UserType, }; -use super::helpers::look_for_ip; - #[allow(clippy::upper_case_acronyms)] #[derive(Clone, Debug, ValueEnum)] pub enum DzMode { @@ -55,8 +53,9 @@ impl DecommissioningCliCommand { // READY spinner.println("🔍 Decommissioning User"); - // Get public IP - let (client_ip, _) = look_for_ip(&self.client_ip, &spinner).await?; + // Get client IP from daemon (same source as connect) + let client_ip = super::helpers::resolve_client_ip(&controller).await?; + spinner.println(format!(" Client IP: {client_ip}")); spinner.inc(1); spinner.set_message("deleting user account..."); @@ -214,7 +213,11 @@ impl DecommissioningCliCommand { #[cfg(test)] mod tests { use super::*; - use crate::servicecontroller::{DoubleZeroStatus, MockServiceController, StatusResponse}; + use std::net::Ipv4Addr; + + use crate::servicecontroller::{ + DoubleZeroStatus, MockServiceController, StatusResponse, V2StatusResponse, + }; fn test_cmd() -> DecommissioningCliCommand { DecommissioningCliCommand { @@ -361,4 +364,70 @@ mod tests { "should have polled at least twice" ); } + + fn v2_status_with_ip(client_ip: &str) -> V2StatusResponse { + V2StatusResponse { + reconciler_enabled: true, + client_ip: client_ip.to_string(), + network: "mainnet".to_string(), + services: vec![], + } + } + + #[tokio::test] + async fn test_resolve_client_ip_success() { + let mut mock = MockServiceController::new(); + mock.expect_v2_status() + .returning(|| Ok(v2_status_with_ip("1.2.3.4"))); + + let ip = crate::command::helpers::resolve_client_ip(&mock) + .await + .unwrap(); + assert_eq!(ip, Ipv4Addr::new(1, 2, 3, 4)); + } + + #[tokio::test] + async fn test_resolve_client_ip_empty() { + let mut mock = MockServiceController::new(); + mock.expect_v2_status() + .returning(|| Ok(v2_status_with_ip(""))); + + let err = crate::command::helpers::resolve_client_ip(&mock) + .await + .unwrap_err(); + assert!( + err.to_string().contains("has not discovered its client IP"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_resolve_client_ip_invalid() { + let mut mock = MockServiceController::new(); + mock.expect_v2_status() + .returning(|| Ok(v2_status_with_ip("not-an-ip"))); + + let err = crate::command::helpers::resolve_client_ip(&mock) + .await + .unwrap_err(); + assert!( + err.to_string().contains("invalid client IP 'not-an-ip'"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_resolve_client_ip_daemon_unreachable() { + let mut mock = MockServiceController::new(); + mock.expect_v2_status() + .returning(|| Err(eyre::eyre!("connection refused"))); + + let err = crate::command::helpers::resolve_client_ip(&mock) + .await + .unwrap_err(); + assert!( + err.to_string().contains("connection refused"), + "unexpected error: {err}" + ); + } } diff --git a/client/doublezero/src/command/helpers.rs b/client/doublezero/src/command/helpers.rs index 4a74bdd09b..5545433829 100644 --- a/client/doublezero/src/command/helpers.rs +++ b/client/doublezero/src/command/helpers.rs @@ -1,313 +1,19 @@ -use backon::{BlockingRetryable, ExponentialBuilder}; -use doublezero_cli::helpers::get_public_ipv4; -use indicatif::ProgressBar; -use std::{ - net::{Ipv4Addr, UdpSocket}, - time::Duration, -}; - -pub async fn look_for_ip( - client_ip: &Option, - spinner: &ProgressBar, -) -> eyre::Result<(Ipv4Addr, String)> { - look_for_ip_with(client_ip, spinner, discover_public_ip).await -} - -/// Discovers the client's public IP address. -/// -/// Resolution order: -/// 1. Ask the kernel for the default route's source address (via a UDP -/// connect to 8.8.8.8 — no packets are sent). If the source is a -/// publicly routable IPv4 address, use it. -/// 2. Fall back to querying ifconfig.me/ip. -/// -/// This matches the daemon's discovery logic so both always agree on the IP. -fn discover_public_ip() -> Result> { - // Try default route source hint first. - if let Ok(ip) = discover_from_default_route() { - return Ok(ip.to_string()); - } - - // Fall back to external discovery. - get_public_ipv4() -} - -/// Performs a kernel route lookup by binding a UDP socket to a well-known -/// public IP. The local address chosen by the kernel reflects the default -/// route's source hint. Returns the IP only if it's publicly routable. -fn discover_from_default_route() -> Result> { - let socket = UdpSocket::bind("0.0.0.0:0")?; - socket.connect("8.8.8.8:80")?; - let local_addr = socket.local_addr()?; - let ip = match local_addr.ip() { - std::net::IpAddr::V4(ip) => ip, - _ => return Err("default route source is not IPv4".into()), - }; - if ip.is_loopback() - || ip.is_private() - || ip.is_link_local() - || ip.is_multicast() - || ip.is_broadcast() - || ip.is_unspecified() - { - return Err(format!("default route source {ip} is not publicly routable").into()); - } - Ok(ip) -} - -async fn look_for_ip_with( - client_ip: &Option, - spinner: &ProgressBar, - ip_fetch_func: impl FnMut() -> Result>, -) -> eyre::Result<(Ipv4Addr, String)> { - let client_ip = match client_ip { - Some(ip) => { - spinner.println(format!(" Using Public IP: {ip}")); - ip - } - None => &{ - spinner.set_message("Discovering your public IP..."); - - let builder = ExponentialBuilder::new() - .with_max_times(3) - .with_min_delay(Duration::from_secs(1)); - - let ipv4 = ip_fetch_func - .retry(builder) - .notify(|_, dur| { - spinner.set_message(format!("Fetching IP Address (checking in {dur:?})...")) - }) - .call() - .map_err(|_| eyre::eyre!("Timeout waiting for IP address")); - - match ipv4 { - Ok(ip) => { - spinner.println(format!("Public IP detected: {ip} - If you want to use a different IP, set --client-ip on the daemon (doublezerod)")); - ip - } - Err(e) => { - eyre::bail!("Could not detect your public IP. Set --client-ip on the daemon (doublezerod). ({})", e.to_string()); - } - } - }, - }; - - let ip: Ipv4Addr = client_ip - .parse() - .map_err(|_| eyre::eyre!("Invalid IPv4 address format: {}", client_ip))?; - - if let Some(reason) = is_bgp_martian(ip) { - eyre::bail!( - "Client IP {} is a BGP martian address ({}). A publicly routable IP address is required.", - ip, - reason - ); - } - - Ok((ip, client_ip.to_string())) -} - -/// Returns `Some(reason)` if the given IPv4 address is a BGP martian (should -/// never appear as a source in the global routing table), or `None` if the -/// address is publicly routable. -fn is_bgp_martian(ip: Ipv4Addr) -> Option<&'static str> { - let octets = ip.octets(); - - // 0.0.0.0/8 — "this" network (RFC 791) - if octets[0] == 0 { - return Some("\"this\" network (0.0.0.0/8)"); - } - // 10.0.0.0/8 — private (RFC 1918) - if octets[0] == 10 { - return Some("private (10.0.0.0/8)"); - } - // 100.64.0.0/10 — shared / CGNAT (RFC 6598) - if octets[0] == 100 && (octets[1] & 0xC0) == 64 { - return Some("shared/CGNAT (100.64.0.0/10)"); - } - // 127.0.0.0/8 — loopback (RFC 1122) - if octets[0] == 127 { - return Some("loopback (127.0.0.0/8)"); - } - // 169.254.0.0/16 — link-local (RFC 3927) - if octets[0] == 169 && octets[1] == 254 { - return Some("link-local (169.254.0.0/16)"); - } - // 172.16.0.0/12 — private (RFC 1918) - if octets[0] == 172 && (octets[1] & 0xF0) == 16 { - return Some("private (172.16.0.0/12)"); - } - // 192.0.0.0/24 — IETF protocol assignments (RFC 6890) - if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 { - return Some("IETF protocol assignments (192.0.0.0/24)"); - } - // 192.0.2.0/24 — documentation TEST-NET-1 (RFC 5737) - if octets[0] == 192 && octets[1] == 0 && octets[2] == 2 { - return Some("documentation TEST-NET-1 (192.0.2.0/24)"); - } - // 192.168.0.0/16 — private (RFC 1918) - if octets[0] == 192 && octets[1] == 168 { - return Some("private (192.168.0.0/16)"); - } - // 198.51.100.0/24 — documentation TEST-NET-2 (RFC 5737) - if octets[0] == 198 && octets[1] == 51 && octets[2] == 100 { - return Some("documentation TEST-NET-2 (198.51.100.0/24)"); - } - // 203.0.113.0/24 — documentation TEST-NET-3 (RFC 5737) - if octets[0] == 203 && octets[1] == 0 && octets[2] == 113 { - return Some("documentation TEST-NET-3 (203.0.113.0/24)"); - } - // 224.0.0.0/4 — multicast (RFC 5771) - if (octets[0] & 0xF0) == 224 { - return Some("multicast (224.0.0.0/4)"); - } - // 240.0.0.0/4 — reserved for future use (RFC 1112) + broadcast - if octets[0] >= 240 { - return Some("reserved (240.0.0.0/4)"); - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - use indicatif::ProgressBar; - use std::{error::Error, net::Ipv4Addr}; - - // Dummy ProgressBar for testing - fn dummy_spinner() -> ProgressBar { - ProgressBar::hidden() - } - - #[tokio::test] - async fn test_look_for_ip_with_client_ip() { - let client_ip = Some("203.0.114.1".to_string()); - let spinner = dummy_spinner(); - - let mock_ip_fetch = || Ok("8.8.8.8".to_string()); - - let result = look_for_ip_with(&client_ip, &spinner, mock_ip_fetch) - .await - .unwrap(); - assert_eq!(result.0, Ipv4Addr::new(203, 0, 114, 1)); - assert_eq!(result.1, "203.0.114.1"); - } - - #[tokio::test] - async fn test_look_for_ip_with_fetch_success() { - let client_ip = None; - let spinner = dummy_spinner(); - - let mock_ip_fetch = || Ok("8.8.8.8".to_string()); - - let result = look_for_ip_with(&client_ip, &spinner, mock_ip_fetch) - .await - .unwrap(); - assert_eq!(result.0, Ipv4Addr::new(8, 8, 8, 8)); - assert_eq!(result.1, "8.8.8.8"); - } - - #[tokio::test] - async fn test_look_for_ip_with_fetch_failure() { - let client_ip = None; - let spinner = dummy_spinner(); - - let mock_ip_fetch = - || Err::>(Box::::from("fetch failed")); - - let result = look_for_ip_with(&client_ip, &spinner, mock_ip_fetch).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_look_for_ip_with_invalid_ip() { - let client_ip = Some("not_an_ip".to_string()); - let spinner = dummy_spinner(); - - let mock_ip_fetch = || Ok("8.8.8.8".to_string()); - - let result = look_for_ip_with(&client_ip, &spinner, mock_ip_fetch).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_look_for_ip_rejects_martian_address() { - let spinner = dummy_spinner(); - let mock_ip_fetch = || Ok("8.8.8.8".to_string()); - - // Private (RFC 1918) - let result = - look_for_ip_with(&Some("192.168.1.1".to_string()), &spinner, mock_ip_fetch).await; - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("BGP martian")); - } - - #[tokio::test] - async fn test_look_for_ip_rejects_auto_detected_martian() { - let spinner = dummy_spinner(); - // Simulate auto-detection returning a private IP - let mock_ip_fetch = || Ok("10.0.0.1".to_string()); - - let result = look_for_ip_with(&None, &spinner, mock_ip_fetch).await; - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("BGP martian")); - } - - #[test] - fn test_is_bgp_martian() { - // Martian addresses - assert!(is_bgp_martian(Ipv4Addr::new(0, 0, 0, 0)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(0, 1, 2, 3)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(10, 0, 0, 1)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(10, 255, 255, 255)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(100, 64, 0, 1)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(100, 127, 255, 255)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(127, 0, 0, 1)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(169, 254, 1, 1)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(172, 16, 0, 1)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(172, 31, 255, 255)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(192, 0, 0, 1)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(192, 0, 2, 1)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(192, 168, 0, 1)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(198, 51, 100, 1)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(203, 0, 113, 1)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(224, 0, 0, 1)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(239, 255, 255, 255)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(240, 0, 0, 1)).is_some()); - assert!(is_bgp_martian(Ipv4Addr::new(255, 255, 255, 255)).is_some()); - - // Non-martian (publicly routable) - assert!(is_bgp_martian(Ipv4Addr::new(1, 1, 1, 1)).is_none()); - assert!(is_bgp_martian(Ipv4Addr::new(8, 8, 8, 8)).is_none()); - assert!(is_bgp_martian(Ipv4Addr::new(100, 63, 255, 255)).is_none()); // just below CGNAT - assert!(is_bgp_martian(Ipv4Addr::new(100, 128, 0, 0)).is_none()); // just above CGNAT - assert!(is_bgp_martian(Ipv4Addr::new(172, 15, 255, 255)).is_none()); // just below 172.16/12 - assert!(is_bgp_martian(Ipv4Addr::new(172, 32, 0, 0)).is_none()); // just above 172.16/12 - assert!(is_bgp_martian(Ipv4Addr::new(198, 18, 0, 1)).is_none()); // benchmarking — allowed - assert!(is_bgp_martian(Ipv4Addr::new(198, 19, 0, 1)).is_none()); // benchmarking — allowed - assert!(is_bgp_martian(Ipv4Addr::new(203, 0, 114, 1)).is_none()); // just above TEST-NET-3 - } - - #[tokio::test] - async fn test_look_for_ip_with_retry_success() { - let client_ip = None; - let spinner = dummy_spinner(); - - let mut first = true; - let mock_ip_fetch = move || { - if first { - first = false; - Err(Box::::from("fetch failed")) - } else { - Ok("8.8.4.4".to_string()) - } - }; - - let result = look_for_ip_with(&client_ip, &spinner, mock_ip_fetch) - .await - .unwrap(); - assert_eq!(result.0, Ipv4Addr::new(8, 8, 4, 4)); - assert_eq!(result.1, "8.8.4.4"); - } +use std::net::Ipv4Addr; + +use crate::servicecontroller::ServiceController; + +pub async fn resolve_client_ip(controller: &T) -> eyre::Result { + let v2_status = controller.v2_status().await?; + if v2_status.client_ip.is_empty() { + return Err(eyre::eyre!( + "Daemon has not discovered its client IP. Ensure the daemon is running \ + and has started up successfully, or set --client-ip on the daemon." + )); + } + v2_status.client_ip.parse().map_err(|e| { + eyre::eyre!( + "Daemon returned invalid client IP '{}': {e}", + v2_status.client_ip + ) + }) } diff --git a/e2e/allocation_lifecycle_test.go b/e2e/allocation_lifecycle_test.go index fa454941e6..84f1cbc03b 100644 --- a/e2e/allocation_lifecycle_test.go +++ b/e2e/allocation_lifecycle_test.go @@ -736,7 +736,7 @@ func TestE2E_Multicast_ReactivationPreservesAllocations(t *testing.T) { // Phase 1: Initial activation as Multicast publisher // ========================================================================= log.Debug("==> Phase 1: Connecting as multicast publisher to first group") - _, err = client.Exec(ctx, []string{"bash", "-c", "doublezero connect multicast publisher test-mc01 --client-ip " + client.CYOANetworkIP}) + _, err = client.Exec(ctx, []string{"bash", "-c", "doublezero connect multicast publisher test-mc01"}) require.NoError(t, err, "failed to connect as multicast publisher") // Wait for user to be activated @@ -792,7 +792,7 @@ func TestE2E_Multicast_ReactivationPreservesAllocations(t *testing.T) { log.Debug("==> Phase 2: Disconnecting and reconnecting with both multicast groups to trigger re-activation") // Disconnect existing multicast service first (required — daemon doesn't support updating in-place) - _, err = client.Exec(ctx, []string{"bash", "-c", "doublezero disconnect multicast --client-ip " + client.CYOANetworkIP}) + _, err = client.Exec(ctx, []string{"bash", "-c", "doublezero disconnect multicast"}) require.NoError(t, err, "failed to disconnect multicast") // Wait for daemon to fully tear down the multicast service before reconnecting @@ -806,7 +806,7 @@ func TestE2E_Multicast_ReactivationPreservesAllocations(t *testing.T) { }, 30*time.Second, 2*time.Second, "daemon did not tear down multicast service within timeout") // Reconnect with both pub groups in a single command - _, err = client.Exec(ctx, []string{"bash", "-c", "doublezero connect multicast --publish test-mc01 test-mc02 --client-ip " + client.CYOANetworkIP}) + _, err = client.Exec(ctx, []string{"bash", "-c", "doublezero connect multicast --publish test-mc01 test-mc02"}) require.NoError(t, err, "failed to reconnect with both pub groups") // ========================================================================= diff --git a/e2e/ibrl_multicast_coexistence_test.go b/e2e/ibrl_multicast_coexistence_test.go index 2713bb078e..2b6082cdad 100644 --- a/e2e/ibrl_multicast_coexistence_test.go +++ b/e2e/ibrl_multicast_coexistence_test.go @@ -115,7 +115,7 @@ func runSingleClientMulticastPubSubSwapTest(t *testing.T, log *slog.Logger, dn * // Connect IBRL client log.Info("==> Connecting client with IBRL") - ibrlCmd := "doublezero connect ibrl --client-ip " + client.CYOANetworkIP + ibrlCmd := "doublezero connect ibrl" _, err = client.Exec(t.Context(), []string{"bash", "-c", ibrlCmd}) require.NoError(t, err) @@ -152,7 +152,7 @@ func runSingleClientMulticastPubSubSwapTest(t *testing.T, log *slog.Logger, dn * log.Info("==> PHASE 2: Adding multicast subscriber") - mcastCmd := "doublezero connect multicast subscriber mg01 --client-ip " + client.CYOANetworkIP + " 2>&1" + mcastCmd := "doublezero connect multicast subscriber mg01 2>&1" mcastOutput, err := client.Exec(t.Context(), []string{"bash", "-c", mcastCmd}) log.Info("==> Multicast subscriber connect output", "output", string(mcastOutput)) require.NoError(t, err) @@ -175,7 +175,7 @@ func runSingleClientMulticastPubSubSwapTest(t *testing.T, log *slog.Logger, dn * log.Info("==> PHASE 3: Removing multicast subscriber") - _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast --client-ip " + client.CYOANetworkIP}) + _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast"}) require.NoError(t, err) log.Info("--> Multicast subscriber disconnected") @@ -193,7 +193,7 @@ func runSingleClientMulticastPubSubSwapTest(t *testing.T, log *slog.Logger, dn * log.Info("==> PHASE 4: Adding multicast publisher") - mcastCmd = "doublezero connect multicast publisher mg01 --client-ip " + client.CYOANetworkIP + " 2>&1" + mcastCmd = "doublezero connect multicast publisher mg01 2>&1" mcastOutput, err = client.Exec(t.Context(), []string{"bash", "-c", mcastCmd}) log.Info("==> Multicast publisher connect output", "output", string(mcastOutput)) require.NoError(t, err) @@ -216,7 +216,7 @@ func runSingleClientMulticastPubSubSwapTest(t *testing.T, log *slog.Logger, dn * log.Info("==> PHASE 5: Removing multicast publisher") - _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast --client-ip " + client.CYOANetworkIP}) + _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast"}) require.NoError(t, err) log.Info("--> Multicast publisher disconnected") @@ -234,7 +234,7 @@ func runSingleClientMulticastPubSubSwapTest(t *testing.T, log *slog.Logger, dn * log.Info("==> PHASE 6: Re-adding multicast subscriber (swap from publisher back to subscriber)") - mcastCmd = "doublezero connect multicast subscriber mg01 --client-ip " + client.CYOANetworkIP + " 2>&1" + mcastCmd = "doublezero connect multicast subscriber mg01 2>&1" mcastOutput, err = client.Exec(t.Context(), []string{"bash", "-c", mcastCmd}) log.Info("==> Multicast subscriber connect output", "output", string(mcastOutput)) require.NoError(t, err) @@ -258,13 +258,13 @@ func runSingleClientMulticastPubSubSwapTest(t *testing.T, log *slog.Logger, dn * log.Info("==> DISCONNECT PHASE") log.Info("==> Disconnecting multicast") - _, disconnectMcastErr := client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast --client-ip " + client.CYOANetworkIP}) + _, disconnectMcastErr := client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast"}) if disconnectMcastErr != nil { log.Info("--> Warning: Multicast disconnect failed", "error", disconnectMcastErr) } log.Info("==> Disconnecting IBRL") - _, disconnectErr := client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect --client-ip " + client.CYOANetworkIP}) + _, disconnectErr := client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect"}) if disconnectErr != nil { log.Info("--> Warning: IBRL disconnect failed", "error", disconnectErr) } else { @@ -316,7 +316,7 @@ func TestE2E_Multicast_PublisherAndSubscriber(t *testing.T) { // Connect as both publisher and subscriber using new flags log.Debug("==> Connecting as both publisher and subscriber") - cmd := "doublezero connect multicast --publish mg01 --subscribe mg01 --client-ip " + client.CYOANetworkIP + " 2>&1" + cmd := "doublezero connect multicast --publish mg01 --subscribe mg01 2>&1" output, err := client.Exec(t.Context(), []string{"bash", "-c", cmd}) log.Debug("==> Connect output", "output", string(output)) require.NoError(t, err, "should be able to connect as both publisher and subscriber") @@ -348,7 +348,7 @@ func TestE2E_Multicast_PublisherAndSubscriber(t *testing.T) { // Disconnect log.Debug("==> Disconnecting") - disconnectOutput, disconnectErr := client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast --client-ip " + client.CYOANetworkIP + " 2>&1"}) + disconnectOutput, disconnectErr := client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast 2>&1"}) log.Debug("==> Disconnect output", "output", string(disconnectOutput)) require.NoError(t, disconnectErr, "disconnect should succeed") verifyTunnelRemoved(t, client, "doublezero1") @@ -496,7 +496,7 @@ func runSingleClientIBRLThenMulticastTest(t *testing.T, log *slog.Logger, dn *de // Connect IBRL client (latency-based device selection) log.Debug("==> Connecting client with IBRL", "useAllocatedAddr", useAllocatedAddr) - ibrlCmd := "doublezero connect ibrl --client-ip " + client.CYOANetworkIP + ibrlCmd := "doublezero connect ibrl" if useAllocatedAddr { ibrlCmd += " --allocate-addr" } @@ -542,10 +542,10 @@ func runSingleClientIBRLThenMulticastTest(t *testing.T, log *slog.Logger, dn *de var mcastCmd string if asPublisher { log.Debug("==> Adding multicast publisher subscription") - mcastCmd = "doublezero connect multicast publisher mg01 --client-ip " + client.CYOANetworkIP + " 2>&1" + mcastCmd = "doublezero connect multicast publisher mg01 2>&1" } else { log.Debug("==> Adding multicast subscriber subscription") - mcastCmd = "doublezero connect multicast subscriber mg01 --client-ip " + client.CYOANetworkIP + " 2>&1" + mcastCmd = "doublezero connect multicast subscriber mg01 2>&1" } mcastOutput, err := client.Exec(t.Context(), []string{"bash", "-c", mcastCmd}) log.Debug("==> Multicast connect command output", "output", string(mcastOutput)) @@ -635,7 +635,7 @@ func runSingleClientIBRLThenMulticastTest(t *testing.T, log *slog.Logger, dn *de // Disconnect multicast first log.Debug("==> Disconnecting multicast") - _, disconnectMcastErr := client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast --client-ip " + client.CYOANetworkIP}) + _, disconnectMcastErr := client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast"}) if disconnectMcastErr != nil { log.Debug("--> Warning: Multicast disconnect failed (ledger may be unavailable)", "error", disconnectMcastErr) } @@ -647,7 +647,7 @@ func runSingleClientIBRLThenMulticastTest(t *testing.T, log *slog.Logger, dn *de // Disconnect IBRL log.Debug("==> Disconnecting IBRL") - _, disconnectErr := client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect --client-ip " + client.CYOANetworkIP}) + _, disconnectErr := client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect"}) if disconnectErr != nil { log.Debug("--> Warning: IBRL disconnect failed (ledger may be unavailable)", "error", disconnectErr) } else { @@ -795,7 +795,7 @@ func runIBRLWithMulticastSubscriberTest(t *testing.T, log *slog.Logger, dn *devn // Connect IBRL client log.Debug("==> Connecting IBRL client", "useAllocatedAddr", useAllocatedAddr) - ibrlCmd := "doublezero connect ibrl --client-ip " + ibrlClient.CYOANetworkIP + ibrlCmd := "doublezero connect ibrl" if useAllocatedAddr { ibrlCmd += " --allocate-addr" } @@ -804,7 +804,7 @@ func runIBRLWithMulticastSubscriberTest(t *testing.T, log *slog.Logger, dn *devn // Connect multicast subscriber log.Debug("==> Connecting multicast subscriber") - _, err = mcastClient.Exec(t.Context(), []string{"bash", "-c", "doublezero connect multicast subscriber mg01 --client-ip " + mcastClient.CYOANetworkIP}) + _, err = mcastClient.Exec(t.Context(), []string{"bash", "-c", "doublezero connect multicast subscriber mg01"}) require.NoError(t, err) // Wait for tunnels to come up @@ -857,7 +857,7 @@ func runIBRLWithMulticastSubscriberTest(t *testing.T, log *slog.Logger, dn *devn // Disconnect multicast subscriber - don't fail if ledger is unavailable log.Debug("==> Disconnecting multicast subscriber to test independence") - _, disconnectMcastErr := mcastClient.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast --client-ip " + mcastClient.CYOANetworkIP}) + _, disconnectMcastErr := mcastClient.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast"}) if disconnectMcastErr != nil { log.Debug("--> Warning: Multicast disconnect failed (ledger may be unavailable)", "error", disconnectMcastErr) return @@ -872,7 +872,7 @@ func runIBRLWithMulticastSubscriberTest(t *testing.T, log *slog.Logger, dn *devn log.Debug("==> FULL DISCONNECT PHASE") // Disconnect IBRL client - don't fail test if ledger is unavailable (infrastructure flakiness) - _, disconnectErr := ibrlClient.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect --client-ip " + ibrlClient.CYOANetworkIP}) + _, disconnectErr := ibrlClient.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect"}) if disconnectErr != nil { log.Debug("--> Warning: IBRL disconnect failed (ledger may be unavailable)", "error", disconnectErr) } else { @@ -907,7 +907,7 @@ func runIBRLWithMulticastPublisherTest(t *testing.T, log *slog.Logger, dn *devne // Connect IBRL client log.Debug("==> Connecting IBRL client", "useAllocatedAddr", useAllocatedAddr) - ibrlCmd := "doublezero connect ibrl --client-ip " + ibrlClient.CYOANetworkIP + ibrlCmd := "doublezero connect ibrl" if useAllocatedAddr { ibrlCmd += " --allocate-addr" } @@ -916,7 +916,7 @@ func runIBRLWithMulticastPublisherTest(t *testing.T, log *slog.Logger, dn *devne // Connect multicast publisher log.Debug("==> Connecting multicast publisher") - _, err = mcastClient.Exec(t.Context(), []string{"bash", "-c", "doublezero connect multicast publisher mg01 --client-ip " + mcastClient.CYOANetworkIP}) + _, err = mcastClient.Exec(t.Context(), []string{"bash", "-c", "doublezero connect multicast publisher mg01"}) require.NoError(t, err) // Wait for tunnels to come up @@ -961,7 +961,7 @@ func runIBRLWithMulticastPublisherTest(t *testing.T, log *slog.Logger, dn *devne // Disconnect multicast publisher - don't fail if ledger is unavailable log.Debug("==> Disconnecting multicast publisher to test independence") - _, disconnectMcastErr := mcastClient.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast --client-ip " + mcastClient.CYOANetworkIP}) + _, disconnectMcastErr := mcastClient.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast"}) if disconnectMcastErr != nil { log.Debug("--> Warning: Multicast disconnect failed (ledger may be unavailable)", "error", disconnectMcastErr) return @@ -976,7 +976,7 @@ func runIBRLWithMulticastPublisherTest(t *testing.T, log *slog.Logger, dn *devne log.Debug("==> FULL DISCONNECT PHASE") // Disconnect IBRL client - don't fail test if ledger is unavailable (infrastructure flakiness) - _, disconnectErr := ibrlClient.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect --client-ip " + ibrlClient.CYOANetworkIP}) + _, disconnectErr := ibrlClient.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect"}) if disconnectErr != nil { log.Debug("--> Warning: IBRL disconnect failed (ledger may be unavailable)", "error", disconnectErr) log.Debug("--> Skipping tunnel removal verification due to disconnect failure") diff --git a/e2e/main_test.go b/e2e/main_test.go index 6e1d4be433..accb906926 100644 --- a/e2e/main_test.go +++ b/e2e/main_test.go @@ -367,7 +367,7 @@ func (dn *TestDevnet) Start(t *testing.T) (*devnet.Device, *devnet.Client) { func (dn *TestDevnet) DisconnectUserTunnel(t *testing.T, client *devnet.Client) { dn.log.Debug("==> Disconnecting user tunnel") - _, err := client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect --client-ip " + client.CYOANetworkIP}) + _, err := client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect"}) require.NoError(t, err) dn.log.Debug("--> User tunnel disconnected") @@ -452,7 +452,7 @@ func (dn *TestDevnet) ConnectIBRLUserTunnel(t *testing.T, client *devnet.Client) _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", "doublezero access-pass set --accesspass-type prepaid --client-ip " + client.CYOANetworkIP + " --user-payer " + client.Pubkey}) require.NoError(t, err) - _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect ibrl --client-ip " + client.CYOANetworkIP}) + _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect ibrl"}) require.NoError(t, err) dn.log.Debug("--> IBRL user tunnel connected") @@ -466,7 +466,7 @@ func (dn *TestDevnet) ConnectUserTunnelWithAllocatedIP(t *testing.T, client *dev _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", "doublezero access-pass set --accesspass-type prepaid --client-ip " + client.CYOANetworkIP + " --user-payer " + client.Pubkey}) require.NoError(t, err) - _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect ibrl --client-ip " + client.CYOANetworkIP + " --allocate-addr"}) + _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect ibrl --allocate-addr"}) require.NoError(t, err) dn.log.Debug("--> User tunnel with allocated IP connected") @@ -480,7 +480,7 @@ func (dn *TestDevnet) ConnectMulticastPublisher(t *testing.T, client *devnet.Cli require.NoError(t, err) groupArgs := strings.Join(multicastGroupCodes, " ") - _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect multicast publisher " + groupArgs + " --client-ip " + client.CYOANetworkIP}) + _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect multicast publisher " + groupArgs}) require.NoError(t, err, "failed to connect multicast publisher") dn.log.Debug("--> Multicast publisher connected") @@ -490,7 +490,7 @@ func (dn *TestDevnet) ConnectMulticastPublisherSkipAccessPass(t *testing.T, clie dn.log.Debug("==> Connecting multicast publisher", "clientIP", client.CYOANetworkIP, "groups", multicastGroupCodes) groupArgs := strings.Join(multicastGroupCodes, " ") - _, err := client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect multicast publisher " + groupArgs + " --client-ip " + client.CYOANetworkIP}) + _, err := client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect multicast publisher " + groupArgs}) require.NoError(t, err, "failed to connect multicast publisher") dn.log.Debug("--> Multicast publisher connected") @@ -504,7 +504,7 @@ func (dn *TestDevnet) DisconnectMulticastPublisher(t *testing.T, client *devnet. _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", "doublezero access-pass set --accesspass-type prepaid --client-ip " + client.CYOANetworkIP + " --user-payer " + client.Pubkey}) require.NoError(t, err) - _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast --client-ip " + client.CYOANetworkIP}) + _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast"}) require.NoError(t, err, "failed to disconnect multicast publisher") dn.log.Debug("--> Multicast publisher disconnected") @@ -518,7 +518,7 @@ func (dn *TestDevnet) ConnectMulticastSubscriber(t *testing.T, client *devnet.Cl require.NoError(t, err) groupArgs := strings.Join(multicastGroupCodes, " ") - _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect multicast subscriber " + groupArgs + " --client-ip " + client.CYOANetworkIP}) + _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect multicast subscriber " + groupArgs}) require.NoError(t, err) dn.log.Debug("--> Multicast subscriber connected") @@ -528,7 +528,7 @@ func (dn *TestDevnet) ConnectMulticastSubscriberSkipAccessPass(t *testing.T, cli dn.log.Debug("==> Connecting multicast subscriber", "clientIP", client.CYOANetworkIP, "groups", multicastGroupCodes) groupArgs := strings.Join(multicastGroupCodes, " ") - _, err := client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect multicast subscriber " + groupArgs + " --client-ip " + client.CYOANetworkIP}) + _, err := client.Exec(t.Context(), []string{"bash", "-c", "doublezero connect multicast subscriber " + groupArgs}) require.NoError(t, err) dn.log.Debug("--> Multicast subscriber connected") @@ -541,7 +541,7 @@ func (dn *TestDevnet) DisconnectMulticastSubscriber(t *testing.T, client *devnet _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", "doublezero access-pass set --accesspass-type prepaid --client-ip " + client.CYOANetworkIP + " --user-payer " + client.Pubkey}) require.NoError(t, err) - _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast --client-ip " + client.CYOANetworkIP}) + _, err = client.Exec(t.Context(), []string{"bash", "-c", "doublezero disconnect multicast"}) require.NoError(t, err) dn.log.Debug("--> Multicast subscriber disconnected") diff --git a/e2e/multi_tunnel_endpoint_test.go b/e2e/multi_tunnel_endpoint_test.go index 30b0865272..602de33c72 100644 --- a/e2e/multi_tunnel_endpoint_test.go +++ b/e2e/multi_tunnel_endpoint_test.go @@ -187,8 +187,8 @@ func runMultiTunnelFallbackTest(t *testing.T, log *slog.Logger, dn *devnet.Devne // Connect IBRL client to device1 using --device flag log.Info("==> Connecting client with IBRL to device1", "device", device1.Spec.Code) - ibrlCmd := fmt.Sprintf("doublezero connect ibrl --client-ip %s --device %s", - client.CYOANetworkIP, device1.Spec.Code) + ibrlCmd := fmt.Sprintf("doublezero connect ibrl --device %s", + device1.Spec.Code) if useAllocatedAddr { ibrlCmd += " --allocate-addr" } @@ -220,8 +220,7 @@ func runMultiTunnelFallbackTest(t *testing.T, log *slog.Logger, dn *devnet.Devne // Connect multicast without specifying device - it should automatically pick device2 // because device1's tunnel endpoint is already in use by the IBRL tunnel - mcastCmd := fmt.Sprintf("doublezero connect multicast subscriber mg01 --client-ip %s 2>&1", - client.CYOANetworkIP) + mcastCmd := "doublezero connect multicast subscriber mg01 2>&1" mcastOutput, err := client.Exec(t.Context(), []string{"bash", "-c", mcastCmd}) log.Info("==> Multicast connect output", "output", string(mcastOutput)) require.NoError(t, err) @@ -287,7 +286,7 @@ func runMultiTunnelFallbackTest(t *testing.T, log *slog.Logger, dn *devnet.Devne log.Info("==> Disconnecting multicast") _, err = client.Exec(t.Context(), []string{ "bash", "-c", - "doublezero disconnect multicast --client-ip " + client.CYOANetworkIP, + "doublezero disconnect multicast", }) if err != nil { log.Info("--> Warning: multicast disconnect returned error", "error", err) @@ -305,7 +304,7 @@ func runMultiTunnelFallbackTest(t *testing.T, log *slog.Logger, dn *devnet.Devne log.Info("==> Disconnecting IBRL") _, err = client.Exec(t.Context(), []string{ "bash", "-c", - "doublezero disconnect --client-ip " + client.CYOANetworkIP, + "doublezero disconnect", }) if err != nil { log.Info("--> Warning: IBRL disconnect returned error", "error", err) @@ -511,7 +510,7 @@ func runSimultaneousTunnelTest(t *testing.T, log *slog.Logger, dn *devnet.Devnet // Connect IBRL. With only one device, the CLI auto-selects it. // The client picks the best tunnel endpoint based on latency probing. log.Info("==> Connecting client with IBRL") - ibrlCmd := fmt.Sprintf("doublezero connect ibrl --client-ip %s", client.CYOANetworkIP) + ibrlCmd := "doublezero connect ibrl" _, err = client.Exec(t.Context(), []string{"bash", "-c", ibrlCmd}) require.NoError(t, err) @@ -543,8 +542,7 @@ func runSimultaneousTunnelTest(t *testing.T, log *slog.Logger, dn *devnet.Devnet // Connect multicast subscriber. The client's exclude_ips list contains the // first tunnel's endpoint, so it selects the remaining endpoint on this device. - mcastCmd := fmt.Sprintf("doublezero connect multicast subscriber mg01 --client-ip %s 2>&1", - client.CYOANetworkIP) + mcastCmd := "doublezero connect multicast subscriber mg01 2>&1" mcastOutput, err := client.Exec(t.Context(), []string{"bash", "-c", mcastCmd}) log.Info("==> Multicast connect output", "output", string(mcastOutput)) require.NoError(t, err) @@ -616,7 +614,7 @@ func runSimultaneousTunnelTest(t *testing.T, log *slog.Logger, dn *devnet.Devnet log.Info("==> Disconnecting multicast") _, err = client.Exec(t.Context(), []string{ "bash", "-c", - "doublezero disconnect multicast --client-ip " + client.CYOANetworkIP, + "doublezero disconnect multicast", }) if err != nil { log.Info("--> Warning: multicast disconnect returned error", "error", err) @@ -634,7 +632,7 @@ func runSimultaneousTunnelTest(t *testing.T, log *slog.Logger, dn *devnet.Devnet log.Info("==> Disconnecting IBRL") _, err = client.Exec(t.Context(), []string{ "bash", "-c", - "doublezero disconnect --client-ip " + client.CYOANetworkIP, + "doublezero disconnect", }) if err != nil { log.Info("--> Warning: IBRL disconnect returned error", "error", err) diff --git a/e2e/user_limits_test.go b/e2e/user_limits_test.go index 7a37b44e2c..26063a22c2 100644 --- a/e2e/user_limits_test.go +++ b/e2e/user_limits_test.go @@ -184,7 +184,7 @@ func TestE2E_UserLimits(t *testing.T) { // Use --device to specify device explicitly (no latency probing needed) _, err = client1.Exec(ctx, []string{"bash", "-c", - "doublezero connect ibrl --device " + deviceCode + " --client-ip " + client1.CYOANetworkIP + " --allocate-addr"}) + "doublezero connect ibrl --device " + deviceCode + " --allocate-addr"}) require.NoError(t, err) // Wait for first user to be activated @@ -234,7 +234,7 @@ func TestE2E_UserLimits(t *testing.T) { // The exit code check is done via require.Error — do not append "; echo EXIT_CODE=$?" // as that would mask the non-zero exit and err would always be nil. output, err := client2.Exec(ctx, []string{"bash", "-c", - "doublezero connect ibrl --device " + deviceCode + " --client-ip " + client2.CYOANetworkIP + " --allocate-addr 2>&1"}) + "doublezero connect ibrl --device " + deviceCode + " --allocate-addr 2>&1"}) outputStr := string(output) log.Info("Second unicast user creation result", "output", outputStr, "err", err) @@ -278,7 +278,7 @@ func TestE2E_UserLimits(t *testing.T) { // Use --device to specify device explicitly (no latency probing needed) _, err = client1.Exec(ctx, []string{"bash", "-c", - "doublezero connect multicast subscriber limit-mc01 --device " + deviceCode + " --client-ip " + client1.CYOANetworkIP}) + "doublezero connect multicast subscriber limit-mc01 --device " + deviceCode + ""}) require.NoError(t, err) // Wait for first multicast subscriber to be activated @@ -333,7 +333,7 @@ func TestE2E_UserLimits(t *testing.T) { // The exit code check is done via require.Error — do not append "; echo EXIT_CODE=$?" // as that would mask the non-zero exit and err would always be nil. output, err := client2.Exec(ctx, []string{"bash", "-c", - "doublezero connect multicast subscriber limit-mc01 --device " + deviceCode + " --client-ip " + client2.CYOANetworkIP + " 2>&1"}) + "doublezero connect multicast subscriber limit-mc01 --device " + deviceCode + " 2>&1"}) outputStr := string(output) log.Info("Second multicast subscriber creation result", "output", outputStr, "err", err) @@ -376,7 +376,7 @@ func TestE2E_UserLimits(t *testing.T) { // Use --device to specify device explicitly (no latency probing needed) _, err = client1.Exec(ctx, []string{"bash", "-c", - "doublezero connect multicast publisher limit-mc01 --device " + deviceCode + " --client-ip " + client1.CYOANetworkIP}) + "doublezero connect multicast publisher limit-mc01 --device " + deviceCode + ""}) require.NoError(t, err) // Wait for first multicast publisher to be activated @@ -431,7 +431,7 @@ func TestE2E_UserLimits(t *testing.T) { // The exit code check is done via require.Error — do not append "; echo EXIT_CODE=$?" // as that would mask the non-zero exit and err would always be nil. output, err := client2.Exec(ctx, []string{"bash", "-c", - "doublezero connect multicast publisher limit-mc01 --device " + deviceCode + " --client-ip " + client2.CYOANetworkIP + " 2>&1"}) + "doublezero connect multicast publisher limit-mc01 --device " + deviceCode + " 2>&1"}) outputStr := string(output) log.Info("Second multicast publisher creation result", "output", outputStr, "err", err)