Skip to content

Commit 764e2e2

Browse files
authored
client: get client IP from daemon in disconnect command (#3341)
## Summary - Get client IP from the daemon's `/v2/status` endpoint in the disconnect command, matching connect's behavior - Extract `resolve_client_ip()` into a testable method with unit tests covering valid IP, empty IP, invalid IP, and daemon-unreachable cases - Remove deprecated `--client-ip` flag from `doublezero connect`/`disconnect` invocations in e2e tests - Move now-unused `look_for_ip` helpers behind `#[cfg(test)]` to fix clippy dead-code warnings - Follow-up to #3034 which moved connect to get the client IP from the daemon but didn't include the same change for disconnect ## Testing Verification - `resolve_client_ip` unit tests cover all error branches (empty, invalid, unreachable) and the happy path - All 82 existing `doublezero` crate tests pass - E2e tests compile cleanly with `--client-ip` removed from connect/disconnect invocations
1 parent 36d5750 commit 764e2e2

File tree

9 files changed

+146
-382
lines changed

9 files changed

+146
-382
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file.
88

99
### Changes
1010

11+
- Client
12+
- Get client IP from the daemon in the disconnect command, matching the connect command's behavior, to avoid IP mismatches behind NAT
13+
1114
## [v0.13.0](https://github.com/malbeclabs/doublezero/compare/client/v0.12.0...client/v0.13.0) - 2026-03-20
1215

1316
### Breaking

client/doublezero/src/command/connect.rs

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -126,19 +126,7 @@ impl ProvisioningCliCommand {
126126
}
127127

128128
// Get public IP from daemon
129-
let v2_status = controller.v2_status().await?;
130-
if v2_status.client_ip.is_empty() {
131-
return Err(eyre::eyre!(
132-
"Daemon has not discovered its client IP. Ensure the daemon is running \
133-
and has started up successfully, or set --client-ip on the daemon."
134-
));
135-
}
136-
let client_ip: Ipv4Addr = v2_status.client_ip.parse().map_err(|e| {
137-
eyre::eyre!(
138-
"Daemon returned invalid client IP '{}': {e}",
139-
v2_status.client_ip
140-
)
141-
})?;
129+
let client_ip = super::helpers::resolve_client_ip(controller).await?;
142130
let client_ip_str = client_ip.to_string();
143131

144132
if !check_accesspass(client, client_ip)? {

client/doublezero/src/command/disconnect.rs

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ use doublezero_sdk::{
2020
UserType,
2121
};
2222

23-
use super::helpers::look_for_ip;
24-
2523
#[allow(clippy::upper_case_acronyms)]
2624
#[derive(Clone, Debug, ValueEnum)]
2725
pub enum DzMode {
@@ -55,8 +53,9 @@ impl DecommissioningCliCommand {
5553
// READY
5654
spinner.println("🔍 Decommissioning User");
5755

58-
// Get public IP
59-
let (client_ip, _) = look_for_ip(&self.client_ip, &spinner).await?;
56+
// Get client IP from daemon (same source as connect)
57+
let client_ip = super::helpers::resolve_client_ip(&controller).await?;
58+
spinner.println(format!(" Client IP: {client_ip}"));
6059

6160
spinner.inc(1);
6261
spinner.set_message("deleting user account...");
@@ -214,7 +213,11 @@ impl DecommissioningCliCommand {
214213
#[cfg(test)]
215214
mod tests {
216215
use super::*;
217-
use crate::servicecontroller::{DoubleZeroStatus, MockServiceController, StatusResponse};
216+
use std::net::Ipv4Addr;
217+
218+
use crate::servicecontroller::{
219+
DoubleZeroStatus, MockServiceController, StatusResponse, V2StatusResponse,
220+
};
218221

219222
fn test_cmd() -> DecommissioningCliCommand {
220223
DecommissioningCliCommand {
@@ -361,4 +364,70 @@ mod tests {
361364
"should have polled at least twice"
362365
);
363366
}
367+
368+
fn v2_status_with_ip(client_ip: &str) -> V2StatusResponse {
369+
V2StatusResponse {
370+
reconciler_enabled: true,
371+
client_ip: client_ip.to_string(),
372+
network: "mainnet".to_string(),
373+
services: vec![],
374+
}
375+
}
376+
377+
#[tokio::test]
378+
async fn test_resolve_client_ip_success() {
379+
let mut mock = MockServiceController::new();
380+
mock.expect_v2_status()
381+
.returning(|| Ok(v2_status_with_ip("1.2.3.4")));
382+
383+
let ip = crate::command::helpers::resolve_client_ip(&mock)
384+
.await
385+
.unwrap();
386+
assert_eq!(ip, Ipv4Addr::new(1, 2, 3, 4));
387+
}
388+
389+
#[tokio::test]
390+
async fn test_resolve_client_ip_empty() {
391+
let mut mock = MockServiceController::new();
392+
mock.expect_v2_status()
393+
.returning(|| Ok(v2_status_with_ip("")));
394+
395+
let err = crate::command::helpers::resolve_client_ip(&mock)
396+
.await
397+
.unwrap_err();
398+
assert!(
399+
err.to_string().contains("has not discovered its client IP"),
400+
"unexpected error: {err}"
401+
);
402+
}
403+
404+
#[tokio::test]
405+
async fn test_resolve_client_ip_invalid() {
406+
let mut mock = MockServiceController::new();
407+
mock.expect_v2_status()
408+
.returning(|| Ok(v2_status_with_ip("not-an-ip")));
409+
410+
let err = crate::command::helpers::resolve_client_ip(&mock)
411+
.await
412+
.unwrap_err();
413+
assert!(
414+
err.to_string().contains("invalid client IP 'not-an-ip'"),
415+
"unexpected error: {err}"
416+
);
417+
}
418+
419+
#[tokio::test]
420+
async fn test_resolve_client_ip_daemon_unreachable() {
421+
let mut mock = MockServiceController::new();
422+
mock.expect_v2_status()
423+
.returning(|| Err(eyre::eyre!("connection refused")));
424+
425+
let err = crate::command::helpers::resolve_client_ip(&mock)
426+
.await
427+
.unwrap_err();
428+
assert!(
429+
err.to_string().contains("connection refused"),
430+
"unexpected error: {err}"
431+
);
432+
}
364433
}

0 commit comments

Comments
 (0)