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) 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/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", ); 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/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); +} 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()