Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 1 addition & 13 deletions client/doublezero/src/command/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)? {
Expand Down
79 changes: 74 additions & 5 deletions client/doublezero/src/command/disconnect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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...");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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}"
);
}
}
Loading
Loading