From 6eaab32ef773e54884302a538086a186dd2a8bff Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Sun, 22 Mar 2026 16:09:13 -0400 Subject: [PATCH 1/4] 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) From b7a16a0e52f224d32c94afade85ea8a33f3ee2a6 Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Sat, 21 Mar 2026 21:01:00 -0400 Subject: [PATCH 2/4] smartcontract: allow payer access pass for multicast group subscribe Accept the access pass from either the user's owner or the payer, enabling a third party (e.g. an oracle) to subscribe an existing user to a new multicast group using its own access pass. --- .../src/processors/multicastgroup/subscribe.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs index 91a00acf9a..ccffea6bc7 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/subscribe.rs @@ -196,12 +196,20 @@ pub fn process_subscribe_multicastgroup( let accesspass = AccessPass::try_from(accesspass_account)?; - let (accesspass_pda, _) = get_accesspass_pda(program_id, &user.client_ip, &user.owner); - let (accesspass_dynamic_pda, _) = + // Accept the access pass from either the user's owner or the caller + // (payer). This allows a third party (e.g. an oracle) to subscribe an + // existing user to a new multicast group using its own access pass. + let (owner_pda, _) = get_accesspass_pda(program_id, &user.client_ip, &user.owner); + let (owner_dynamic_pda, _) = get_accesspass_pda(program_id, &Ipv4Addr::UNSPECIFIED, &user.owner); + let (payer_pda, _) = get_accesspass_pda(program_id, &user.client_ip, payer_account.key); + let (payer_dynamic_pda, _) = + get_accesspass_pda(program_id, &Ipv4Addr::UNSPECIFIED, payer_account.key); assert!( - accesspass_account.key == &accesspass_pda - || accesspass_account.key == &accesspass_dynamic_pda, + accesspass_account.key == &owner_pda + || accesspass_account.key == &owner_dynamic_pda + || accesspass_account.key == &payer_pda + || accesspass_account.key == &payer_dynamic_pda, "Invalid AccessPass PDA", ); From f1194d079d6e2d0551fb87639bbdd57a3e71fa0a Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Sun, 22 Mar 2026 18:54:32 -0400 Subject: [PATCH 3/4] smartcontract: allow payer access pass for user delete Accept the payer's access pass in DeleteUser (same pattern as SubscribeMulticastGroup). Also authorize delete when the payer matches the access pass user_payer field. SDK DeleteUserCommand tries the payer's access pass first, falling back to user.owner's. --- .../src/processors/user/delete.rs | 40 ++++++----- .../tests/user_tests.rs | 68 +++++++++++++++++++ .../sdk/rs/src/commands/user/delete.rs | 68 +++++++++++++++---- 3 files changed, 146 insertions(+), 30 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs index 5407e1c737..070f466760 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/delete.rs @@ -143,33 +143,41 @@ pub fn process_delete_user( let user: User = User::try_from(user_account)?; let globalstate = GlobalState::try_from(globalstate_account)?; - if !globalstate.foundation_allowlist.contains(payer_account.key) - && user.owner != *payer_account.key - { + + // Allow delete if payer is: the user owner, the access pass user_payer, + // or on the foundation allowlist. + let accesspass_user_payer = if !accesspass_account.data_is_empty() { + AccessPass::try_from(accesspass_account) + .map(|ap| ap.user_payer) + .ok() + } else { + None + }; + let is_authorized = globalstate.foundation_allowlist.contains(payer_account.key) + || user.owner == *payer_account.key + || accesspass_user_payer == Some(*payer_account.key); + if !is_authorized { return Err(DoubleZeroError::NotAllowed.into()); } - let (accesspass_pda, _) = get_accesspass_pda(program_id, &user.client_ip, &user.owner); - let (accesspass_dynamic_pda, _) = + // Accept access pass from either user.owner or payer + let (owner_pda, _) = get_accesspass_pda(program_id, &user.client_ip, &user.owner); + let (owner_dynamic_pda, _) = get_accesspass_pda(program_id, &Ipv4Addr::UNSPECIFIED, &user.owner); - // Access Pass must exist and match the client_ip or allow_multiple_ip must be enabled + let (payer_pda, _) = get_accesspass_pda(program_id, &user.client_ip, payer_account.key); + let (payer_dynamic_pda, _) = + get_accesspass_pda(program_id, &Ipv4Addr::UNSPECIFIED, payer_account.key); assert!( - accesspass_account.key == &accesspass_pda - || accesspass_account.key == &accesspass_dynamic_pda, + accesspass_account.key == &owner_pda + || accesspass_account.key == &owner_dynamic_pda + || accesspass_account.key == &payer_pda + || accesspass_account.key == &payer_dynamic_pda, "Invalid AccessPass PDA", ); if !accesspass_account.data_is_empty() { // Read Access Pass let mut accesspass = AccessPass::try_from(accesspass_account)?; - if accesspass.user_payer != user.owner { - msg!( - "Invalid user_payer accesspass.user_payer: {} = user_payer: {} ", - accesspass.user_payer, - user.owner - ); - return Err(DoubleZeroError::Unauthorized.into()); - } if accesspass.is_dynamic() && accesspass.client_ip == Ipv4Addr::UNSPECIFIED { accesspass.client_ip = user.client_ip; // lock to the first used IP } diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs b/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs index 6997f56f22..770e5c55e6 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs @@ -1658,3 +1658,71 @@ async fn test_user_delete_from_out_of_credits() { .unwrap(); assert_eq!(user.status, UserStatus::Deleting); } + +/// Access pass user_payer can delete the user. Also verifies a stranger cannot. +#[tokio::test] +async fn test_user_delete_by_accesspass_user_payer() { + let (mut banks_client, payer, program_id, globalstate_pubkey, user_pubkey, accesspass_pubkey) = + setup_activated_user().await; + + // Verify payer is the access pass user_payer + let accesspass = get_account_data(&mut banks_client, accesspass_pubkey) + .await + .unwrap() + .get_accesspass() + .unwrap(); + assert_eq!(accesspass.user_payer, payer.pubkey()); + + // Stranger should NOT be able to delete + let stranger = Keypair::new(); + transfer(&mut banks_client, &payer, &stranger.pubkey(), 10_000_000).await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let res = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::DeleteUser(UserDeleteArgs { + dz_prefix_count: 0, + multicast_publisher_count: 0, + }), + vec![ + AccountMeta::new(user_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &stranger, + ) + .await; + assert!(res.is_err(), "Stranger should not be able to delete user"); + + // Payer (access pass user_payer) can delete + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let res = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::DeleteUser(UserDeleteArgs { + dz_prefix_count: 0, + multicast_publisher_count: 0, + }), + vec![ + AccountMeta::new(user_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + assert!( + res.is_ok(), + "Access pass user_payer should be able to delete user" + ); + + let user = get_account_data(&mut banks_client, user_pubkey) + .await + .unwrap() + .get_user() + .unwrap(); + assert_eq!(user.status, UserStatus::Deleting); +} diff --git a/smartcontract/sdk/rs/src/commands/user/delete.rs b/smartcontract/sdk/rs/src/commands/user/delete.rs index 0c7a22d185..5178eec096 100644 --- a/smartcontract/sdk/rs/src/commands/user/delete.rs +++ b/smartcontract/sdk/rs/src/commands/user/delete.rs @@ -93,21 +93,40 @@ impl DeleteUserCommand { .map_err(|_| eyre::eyre!("Timeout waiting for user multicast unsubscribe"))?; } - let (accesspass_pk, _) = GetAccessPassCommand { - client_ip: Ipv4Addr::UNSPECIFIED, - user_payer: user.owner, - } - .execute(client)? - .or_else(|| { - GetAccessPassCommand { - client_ip: user.client_ip, - user_payer: user.owner, - } + // Look up access pass: try payer's first, then fall back to user.owner's. + // This allows a third party (e.g. oracle) to delete using its own access pass. + let payer_key = client.get_payer(); + let access_pass_lookup_keys = if payer_key == user.owner { + vec![user.owner] + } else { + vec![payer_key, user.owner] + }; + + let mut accesspass_result = None; + for key in &access_pass_lookup_keys { + let found = (GetAccessPassCommand { + client_ip: Ipv4Addr::UNSPECIFIED, + user_payer: *key, + }) .execute(client) .ok() .flatten() - }) - .ok_or_else(|| eyre::eyre!("You have no Access Pass"))?; + .or_else(|| { + (GetAccessPassCommand { + client_ip: user.client_ip, + user_payer: *key, + }) + .execute(client) + .ok() + .flatten() + }); + if let Some(result) = found { + accesspass_result = Some(result); + break; + } + } + let (accesspass_pk, _) = + accesspass_result.ok_or_else(|| eyre::eyre!("No Access Pass found"))?; let mut accounts = vec![ AccountMeta::new(self.pubkey, false), @@ -806,7 +825,28 @@ mod tests { .in_sequence(&mut seq) .returning(move |_| Ok(AccountData::User(user_final_clone.clone()))); - // Call 8a: UNSPECIFIED AccessPass lookup fails (fallback path) — DeleteUserCommand + // Call 8a: UNSPECIFIED AccessPass lookup for payer (foundation_key) — not found + let (payer_unspecified_ap, _) = + get_accesspass_pda(&program_id, &Ipv4Addr::UNSPECIFIED, &foundation_key); + let user_clone_payer_fallback = user_activated_final.clone(); + client + .expect_get() + .with(predicate::eq(payer_unspecified_ap)) + .times(1) + .in_sequence(&mut seq) + .returning(move |_| Ok(AccountData::User(user_clone_payer_fallback.clone()))); + + // Call 8b: client_ip AccessPass lookup for payer (foundation_key) — not found + let (payer_ip_ap, _) = get_accesspass_pda(&program_id, &client_ip, &foundation_key); + let user_clone_payer_fallback2 = user_activated_final.clone(); + client + .expect_get() + .with(predicate::eq(payer_ip_ap)) + .times(1) + .in_sequence(&mut seq) + .returning(move |_| Ok(AccountData::User(user_clone_payer_fallback2.clone()))); + + // Call 8c: UNSPECIFIED AccessPass lookup for user_owner — not found let user_clone_fallback2 = user_activated_final.clone(); client .expect_get() @@ -815,7 +855,7 @@ mod tests { .in_sequence(&mut seq) .returning(move |_| Ok(AccountData::User(user_clone_fallback2.clone()))); - // Call 8b: AccessPass fetch via client_ip fallback — keyed to (client_ip, user_owner) + // Call 8d: client_ip AccessPass fetch for user_owner — found let accesspass_clone2 = accesspass.clone(); client .expect_get() From ed42d95bf901dbd8c88c3854a5c536db07069169 Mon Sep 17 00:00:00 2001 From: Steven Normore Date: Sun, 22 Mar 2026 19:07:31 -0400 Subject: [PATCH 4/4] smartcontract: add feed_authority to subscriber allowlist remove auth check --- .../allowlist/subscriber/remove.rs | 1 + ...multicastgroup_allowlist_subcriber_test.rs | 164 ++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/allowlist/subscriber/remove.rs b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/allowlist/subscriber/remove.rs index a2dbd1ac2c..fcd320489f 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/allowlist/subscriber/remove.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/multicastgroup/allowlist/subscriber/remove.rs @@ -79,6 +79,7 @@ pub fn process_remove_multicast_sub_allowlist( // Check whether mgroup is authorized let is_authorized = (mgroup.owner == *payer_account.key) || globalstate.sentinel_authority_pk == *payer_account.key + || globalstate.feed_authority_pk == *payer_account.key || globalstate.foundation_allowlist.contains(payer_account.key); if !is_authorized { return Err(DoubleZeroError::NotAllowed.into()); diff --git a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_allowlist_subcriber_test.rs b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_allowlist_subcriber_test.rs index 388d5a1f8f..92e4b8d117 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_allowlist_subcriber_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_allowlist_subcriber_test.rs @@ -761,3 +761,167 @@ async fn test_multicast_subscriber_allowlist_feed_authority_different_user_payer "Non-feed authority should fail when user_payer doesn't match" ); } + +/// Feed authority can remove from subscriber allowlist. +#[tokio::test] +async fn test_multicast_subscriber_allowlist_feed_authority_remove() { + let (mut banks_client, program_id, payer, recent_blockhash) = init_test().await; + + let client_ip = [100, 0, 0, 6].into(); + let user_payer = payer.pubkey(); + + let (program_config_pubkey, _) = get_program_config_pda(&program_id); + let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); + + // 1. Initialize global state + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::InitGlobalState(), + vec![ + AccountMeta::new(program_config_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // 2. Create feed authority + let feed = Keypair::new(); + transfer(&mut banks_client, &payer, &feed.pubkey(), 10_000_000_000).await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetAuthority(SetAuthorityArgs { + feed_authority_pk: Some(feed.pubkey()), + ..Default::default() + }), + vec![AccountMeta::new(globalstate_pubkey, false)], + &payer, + ) + .await; + + // 3. Create and activate multicast group + let globalstate = get_account_data(&mut banks_client, globalstate_pubkey) + .await + .expect("Unable to get Account") + .get_global_state() + .unwrap(); + + let (multicastgroup_pubkey, _) = + get_multicastgroup_pda(&program_id, globalstate.account_index + 1); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateMulticastGroup(MulticastGroupCreateArgs { + code: "feed-remove".to_string(), + max_bandwidth: 1_000_000_000, + owner: payer.pubkey(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(multicastgroup_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateMulticastGroup(MulticastGroupActivateArgs { + multicast_ip: [224, 254, 0, 6].into(), + }), + vec![ + AccountMeta::new(multicastgroup_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // 4. Payer creates access pass and adds allowlist entry + let (accesspass_pubkey, _) = get_accesspass_pda(&program_id, &client_ip, &user_payer); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetAccessPass(SetAccessPassArgs { + accesspass_type: AccessPassType::Prepaid, + client_ip, + last_access_epoch: 100, + allow_multiple_ip: false, + }), + vec![ + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(user_payer, false), + ], + &payer, + ) + .await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::AddMulticastGroupSubAllowlist(AddMulticastGroupSubAllowlistArgs { + client_ip, + user_payer, + }), + vec![ + AccountMeta::new(multicastgroup_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let accesspass = get_account_data(&mut banks_client, accesspass_pubkey) + .await + .expect("Unable to get Account") + .get_accesspass() + .unwrap(); + assert_eq!(accesspass.mgroup_sub_allowlist.len(), 1); + + // 5. Feed authority removes subscriber allowlist entry — should succeed + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let res = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::RemoveMulticastGroupSubAllowlist( + RemoveMulticastGroupSubAllowlistArgs { + client_ip, + user_payer, + }, + ), + vec![ + AccountMeta::new(multicastgroup_pubkey, false), + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &feed, + ) + .await; + assert!( + res.is_ok(), + "Feed authority should be able to remove from subscriber allowlist" + ); + + let accesspass = get_account_data(&mut banks_client, accesspass_pubkey) + .await + .expect("Unable to get Account") + .get_accesspass() + .unwrap(); + assert_eq!(accesspass.mgroup_sub_allowlist.len(), 0); +}