cli.rs
1 #![cfg_attr(target_os = "windows", allow(unused_imports, dead_code))] 2 use std::io::{Read, Write}; 3 use std::net::{TcpListener, TcpStream}; 4 use std::path::Path; 5 use std::sync::{Arc, Barrier}; 6 use std::time::{Duration, Instant}; 7 8 use dumbvpn::SecretKey; 9 10 fn dumbvpn_bin() -> &'static str { 11 env!("CARGO_BIN_EXE_dumbvpn") 12 } 13 14 fn wait2() -> Arc<Barrier> { 15 Arc::new(Barrier::new(2)) 16 } 17 18 const TIMEOUT: Duration = Duration::from_secs(30); 19 20 /// Pre-generated secret for a test endpoint. 21 struct TestSecret { 22 secret_hex: String, 23 public: iroh::PublicKey, 24 } 25 26 fn test_secret() -> TestSecret { 27 let secret = SecretKey::generate(&mut rand::rng()); 28 TestSecret { 29 secret_hex: hex::encode(secret.to_bytes()), 30 public: secret.public(), 31 } 32 } 33 34 /// The shared network secret used for all tests. 35 const TEST_NETWORK_SECRET: &str = "test-network-secret"; 36 37 /// Apply common test env vars to a duct command expression. 38 fn test_env(cmd: duct::Expression, secret: &TestSecret) -> duct::Expression { 39 cmd.env_remove("RUST_LOG") 40 .env(dumbvpn::env::LOCAL_ONLY, "1") 41 .env(dumbvpn::env::PUBLIC, "true") 42 .env(dumbvpn::env::IROH_SECRET, &secret.secret_hex) 43 .env(dumbvpn::env::NETWORK_SECRET, TEST_NETWORK_SECRET) 44 } 45 46 /// Get a free TCP port by briefly binding to port 0. 47 fn free_port() -> u16 { 48 TcpListener::bind("127.0.0.1:0") 49 .unwrap() 50 .local_addr() 51 .unwrap() 52 .port() 53 } 54 55 /// Poll until a file appears and contains a non-empty value, then return its 56 /// contents as a string. 57 fn wait_for_file(path: &Path, timeout: Duration) -> String { 58 let deadline = Instant::now() + timeout; 59 loop { 60 if let Ok(content) = std::fs::read_to_string(path) { 61 if !content.is_empty() { 62 return content; 63 } 64 } 65 if Instant::now() >= deadline { 66 panic!("timeout waiting for {}", path.display()); 67 } 68 std::thread::sleep(Duration::from_millis(25)); 69 } 70 } 71 72 /// Connection info for a test node: the public key string and a direct address. 73 struct TestNodeAddr { 74 node_id: String, 75 direct_addr: String, 76 } 77 78 /// Read the port file and build connection info for a test node. 79 fn read_node_addr(port_path: &Path, public: &iroh::PublicKey, timeout: Duration) -> TestNodeAddr { 80 let port_str = wait_for_file(port_path, timeout); 81 let port: u16 = port_str.trim().parse().unwrap(); 82 TestNodeAddr { 83 node_id: public.to_string(), 84 direct_addr: format!("127.0.0.1:{port}"), 85 } 86 } 87 88 impl TestNodeAddr { 89 /// Return CLI args for a connect-style subcommand. 90 fn connect_args(&self) -> Vec<&str> { 91 vec![&self.node_id, "--direct-addr", &self.direct_addr] 92 } 93 } 94 95 /// Poll until a TCP connection to `addr` succeeds, then return the stream. 96 fn wait_for_tcp_connect(addr: &str, timeout: Duration) -> TcpStream { 97 let deadline = Instant::now() + timeout; 98 loop { 99 if let Ok(stream) = TcpStream::connect(addr) { 100 return stream; 101 } 102 if Instant::now() >= deadline { 103 panic!("timeout waiting for TCP connection to {addr}"); 104 } 105 std::thread::sleep(Duration::from_millis(25)); 106 } 107 } 108 109 /// Verify that a wrong network secret causes the connection to fail. 110 #[test] 111 fn connect_listen_wrong_secret() { 112 let listen_secret = test_secret(); 113 let connect_secret = test_secret(); 114 let port_file = tempfile::NamedTempFile::new().unwrap(); 115 let port_path = port_file.path().to_str().unwrap().to_string(); 116 117 let _listen = test_env( 118 duct::cmd( 119 dumbvpn_bin(), 120 ["listen", "stdio", "--one-shot", "--port-path", &port_path], 121 ), 122 &listen_secret, 123 ) 124 .stdin_bytes(b"hello from listen") 125 .stderr_null() 126 .stdout_capture() 127 .start() 128 .unwrap(); 129 130 let target = read_node_addr(port_file.path(), &listen_secret.public, TIMEOUT); 131 132 // Connect with a different network secret — should fail. 133 let mut args = vec!["connect", "stdio"]; 134 args.extend(target.connect_args()); 135 let connect = duct::cmd(dumbvpn_bin(), &args) 136 .env_remove("RUST_LOG") 137 .env(dumbvpn::env::LOCAL_ONLY, "1") 138 .env(dumbvpn::env::PUBLIC, "true") 139 .env(dumbvpn::env::IROH_SECRET, &connect_secret.secret_hex) 140 .env(dumbvpn::env::NETWORK_SECRET, "wrong-secret") 141 .stdin_bytes(b"hello from connect") 142 .stderr_null() 143 .stdout_capture() 144 .unchecked() 145 .run() 146 .unwrap(); 147 148 assert!(!connect.status.success()); 149 } 150 151 /// Tests the basic functionality of the connect and listen pair 152 /// 153 /// Connect and listen both write a limited amount of data and then EOF. 154 /// The interaction should stop when both sides have EOF'd. 155 #[test] 156 fn connect_listen_happy() { 157 let listen_secret = test_secret(); 158 let connect_secret = test_secret(); 159 let port_file = tempfile::NamedTempFile::new().unwrap(); 160 let port_path = port_file.path().to_str().unwrap().to_string(); 161 162 let listen_to_connect = b"hello from listen"; 163 let connect_to_listen = b"hello from connect"; 164 165 let listen = test_env( 166 duct::cmd( 167 dumbvpn_bin(), 168 ["listen", "stdio", "--one-shot", "--port-path", &port_path], 169 ), 170 &listen_secret, 171 ) 172 .stdin_bytes(listen_to_connect) 173 .stderr_null() 174 .stdout_capture() 175 .start() 176 .unwrap(); 177 178 let target = read_node_addr(port_file.path(), &listen_secret.public, TIMEOUT); 179 let mut args = vec!["connect", "stdio"]; 180 args.extend(target.connect_args()); 181 182 let connect = test_env(duct::cmd(dumbvpn_bin(), &args), &connect_secret) 183 .stdin_bytes(connect_to_listen) 184 .stderr_null() 185 .stdout_capture() 186 .run() 187 .unwrap(); 188 189 assert!(connect.status.success()); 190 assert_eq!(&connect.stdout, listen_to_connect); 191 192 let listen_out = listen.wait().unwrap(); 193 assert_eq!(&listen_out.stdout, connect_to_listen); 194 } 195 196 #[cfg(unix)] 197 #[test] 198 fn connect_listen_ctrlc_connect() { 199 use nix::sys::signal::{self, Signal}; 200 use nix::unistd::Pid; 201 202 let listen_secret = test_secret(); 203 let connect_secret = test_secret(); 204 let port_file = tempfile::NamedTempFile::new().unwrap(); 205 let port_path = port_file.path().to_str().unwrap().to_string(); 206 207 let listen = test_env( 208 duct::cmd( 209 dumbvpn_bin(), 210 ["listen", "stdio", "--one-shot", "--port-path", &port_path], 211 ), 212 &listen_secret, 213 ) 214 .stdin_bytes(b"hello from listen\n") 215 .stderr_null() 216 .stdout_capture() 217 .reader() 218 .unwrap(); 219 220 let target = read_node_addr(port_file.path(), &listen_secret.public, TIMEOUT); 221 let mut args = vec!["connect", "stdio"]; 222 args.extend(target.connect_args()); 223 224 let mut connect = test_env(duct::cmd(dumbvpn_bin(), &args), &connect_secret) 225 .stderr_null() 226 .stdout_capture() 227 .reader() 228 .unwrap(); 229 230 // wait until we get data from the listen process 231 let mut buf = [0u8; 1]; 232 connect.read_exact(&mut buf).unwrap(); 233 234 for pid in connect.pids() { 235 signal::kill(Pid::from_raw(pid as i32), Signal::SIGINT).unwrap(); 236 } 237 238 let mut tmp = Vec::new(); 239 // we don't care about the results. This test is just to make sure that the 240 // listen command stops when the connect command stops. 241 drop(listen); 242 connect.read_to_end(&mut tmp).ok(); 243 } 244 245 #[cfg(unix)] 246 #[test] 247 fn connect_listen_ctrlc_listen() { 248 use nix::sys::signal::{self, Signal}; 249 use nix::unistd::Pid; 250 251 let listen_secret = test_secret(); 252 let connect_secret = test_secret(); 253 let port_file = tempfile::NamedTempFile::new().unwrap(); 254 let port_path = port_file.path().to_str().unwrap().to_string(); 255 256 let mut listen = test_env( 257 duct::cmd( 258 dumbvpn_bin(), 259 ["listen", "stdio", "--one-shot", "--port-path", &port_path], 260 ), 261 &listen_secret, 262 ) 263 .stderr_null() 264 .stdout_capture() 265 .reader() 266 .unwrap(); 267 268 let target = read_node_addr(port_file.path(), &listen_secret.public, TIMEOUT); 269 let mut args = vec!["connect", "stdio"]; 270 args.extend(target.connect_args()); 271 272 let mut connect = test_env(duct::cmd(dumbvpn_bin(), &args), &connect_secret) 273 .stderr_null() 274 .stdout_capture() 275 .reader() 276 .unwrap(); 277 278 // Give the connection time to establish before sending SIGINT. 279 // iroh handles retries internally, but we need the connection to be up 280 // before killing the listener to test graceful shutdown. 281 std::thread::sleep(Duration::from_secs(1)); 282 for pid in listen.pids() { 283 signal::kill(Pid::from_raw(pid as i32), Signal::SIGINT).unwrap(); 284 } 285 286 let mut tmp = Vec::new(); 287 listen.read_to_end(&mut tmp).ok(); 288 connect.read_to_end(&mut tmp).ok(); 289 } 290 291 #[test] 292 #[cfg(unix)] 293 #[ignore = "flaky: race between TCP backend write and connect"] 294 fn listen_tcp_happy() { 295 let b1 = wait2(); 296 let b2 = b1.clone(); 297 let tcp_port = free_port(); 298 let host_port = format!("localhost:{tcp_port}"); 299 let host_port_2 = host_port.clone(); 300 std::thread::spawn(move || { 301 let server = TcpListener::bind(host_port_2).unwrap(); 302 b1.wait(); 303 let (mut stream, _addr) = server.accept().unwrap(); 304 stream.write_all(b"hello from tcp").unwrap(); 305 stream.flush().unwrap(); 306 drop(stream); 307 }); 308 b2.wait(); 309 310 let listen_secret = test_secret(); 311 let connect_secret = test_secret(); 312 let port_file = tempfile::NamedTempFile::new().unwrap(); 313 let port_path = port_file.path().to_str().unwrap().to_string(); 314 315 let _listen_tcp = test_env( 316 duct::cmd( 317 dumbvpn_bin(), 318 [ 319 "listen", 320 "tcp", 321 "--host", 322 &host_port, 323 "--port-path", 324 &port_path, 325 ], 326 ), 327 &listen_secret, 328 ) 329 .stderr_null() 330 .stdout_capture() 331 .start() 332 .unwrap(); 333 334 let target = read_node_addr(port_file.path(), &listen_secret.public, TIMEOUT); 335 let mut args = vec!["connect", "stdio"]; 336 args.extend(target.connect_args()); 337 338 let connect = test_env(duct::cmd(dumbvpn_bin(), &args), &connect_secret) 339 .stderr_null() 340 .stdout_capture() 341 .stdin_bytes(b"hello from connect") 342 .unchecked() 343 .run() 344 .unwrap(); 345 346 assert_eq!(&connect.stdout, b"hello from tcp"); 347 } 348 349 #[test] 350 fn connect_tcp_happy() { 351 let tcp_port = free_port(); 352 let host_port = format!("localhost:{tcp_port}"); 353 354 let listen_secret = test_secret(); 355 let connect_secret = test_secret(); 356 let port_file = tempfile::NamedTempFile::new().unwrap(); 357 let port_path = port_file.path().to_str().unwrap().to_string(); 358 359 let _listen = test_env( 360 duct::cmd( 361 dumbvpn_bin(), 362 ["listen", "stdio", "--one-shot", "--port-path", &port_path], 363 ), 364 &listen_secret, 365 ) 366 .stdin_bytes(b"hello from listen\n") 367 .stderr_null() 368 .stdout_capture() 369 .start() 370 .unwrap(); 371 372 let target = read_node_addr(port_file.path(), &listen_secret.public, TIMEOUT); 373 let mut args = vec!["connect", "tcp", "--bind", &host_port]; 374 args.extend(target.connect_args()); 375 376 let _connect_tcp = test_env(duct::cmd(dumbvpn_bin(), &args), &connect_secret) 377 .stderr_null() 378 .stdout_null() 379 .start() 380 .unwrap(); 381 382 // Wait for connect-tcp to bind its TCP port. 383 let mut conn = wait_for_tcp_connect(&host_port, Duration::from_secs(10)); 384 conn.write_all(b"hello from tcp").unwrap(); 385 conn.flush().unwrap(); 386 let mut buf = Vec::new(); 387 conn.read_to_end(&mut buf).unwrap(); 388 assert_eq!(&buf, b"hello from listen\n"); 389 } 390 391 /// Integration test for Unix-domain socket tunneling. 392 #[cfg(all(test, unix))] 393 mod unix_socket_tests { 394 use std::io::{Read, Write}; 395 use std::net::Shutdown; 396 use std::os::unix::net::{UnixListener, UnixStream}; 397 use std::path::{Path, PathBuf}; 398 use std::sync::{Arc, Barrier}; 399 use std::time::{Duration, Instant}; 400 401 use tempfile::TempDir; 402 403 use super::*; 404 405 /// Polls until the condition returns true or timeout is reached. 406 fn wait_until<F>(timeout: Duration, mut condition: F) 407 where 408 F: FnMut() -> bool, 409 { 410 let deadline = Instant::now() + timeout; 411 while !condition() { 412 if Instant::now() >= deadline { 413 panic!("timeout waiting for condition"); 414 } 415 std::thread::sleep(Duration::from_millis(25)); 416 } 417 } 418 419 /// Waits until a filesystem path exists. 420 fn wait_for_path<P: AsRef<Path>>(path: P, timeout: Duration) { 421 let p = path.as_ref().to_path_buf(); 422 wait_until(timeout, move || p.exists()); 423 } 424 425 /// Generate a temp directory with a Unix socket path 426 fn temp_socket_path() -> (TempDir, PathBuf) { 427 let temp_dir = tempfile::tempdir().unwrap(); 428 let socket_path = temp_dir.path().join("test.sock"); 429 (temp_dir, socket_path) 430 } 431 432 /// Helper to drain stderr from a process in a background thread 433 fn drain_stderr( 434 stderr: std::process::ChildStderr, 435 prefix: &'static str, 436 ) -> std::thread::JoinHandle<()> { 437 std::thread::spawn(move || { 438 use std::io::BufRead; 439 let reader = std::io::BufReader::new(stderr); 440 for line in reader.lines().map_while(Result::ok) { 441 eprintln!("[{prefix}] {line}"); 442 } 443 }) 444 } 445 446 /// A dummy unix server that accepts multiple connections and handles them 447 /// properly. 448 fn dummy_unix_server( 449 socket_path: PathBuf, 450 barrier: Arc<Barrier>, 451 ) -> std::thread::JoinHandle<()> { 452 std::thread::spawn(move || { 453 let _ = std::fs::remove_file(&socket_path); 454 let listener = UnixListener::bind(&socket_path).unwrap(); 455 barrier.wait(); 456 for stream in listener.incoming() { 457 if let Ok(mut stream) = stream { 458 std::thread::spawn(move || { 459 let mut buf = vec![0; 1024]; 460 if let Ok(n) = stream.read(&mut buf) { 461 if 0 < n && stream.write_all(b"hello from unix").is_ok() { 462 stream.shutdown(Shutdown::Write).ok(); 463 } 464 } 465 while 0 < stream.read(&mut buf).unwrap_or(0) {} 466 }); 467 } else { 468 break; 469 } 470 } 471 }) 472 } 473 474 #[test] 475 fn unix_socket_roundtrip() { 476 let (_tmp_dir, backend_sock) = temp_socket_path(); 477 let client_sock = backend_sock.with_extension("client"); 478 479 let barrier = Arc::new(Barrier::new(2)); 480 let _backend_thread = dummy_unix_server(backend_sock.clone(), barrier.clone()); 481 barrier.wait(); 482 483 // Actively probe the backend server to ensure it's accepting connections. 484 let deadline = Instant::now() + Duration::from_secs(5); 485 while Instant::now() < deadline { 486 if UnixStream::connect(&backend_sock).is_ok() { 487 break; 488 } 489 std::thread::sleep(Duration::from_millis(100)); 490 } 491 if UnixStream::connect(&backend_sock).is_err() { 492 panic!("backend server not connectable after 5s"); 493 } 494 495 let listen_secret = test_secret(); 496 let connect_secret = test_secret(); 497 let port_file = tempfile::NamedTempFile::new().unwrap(); 498 let port_path = port_file.path().to_str().unwrap().to_string(); 499 500 // Launch listen-unix targeting the backend. 501 let mut listen_proc = std::process::Command::new(dumbvpn_bin()) 502 .args([ 503 "listen", 504 "unix", 505 "--socket-path", 506 backend_sock.to_str().unwrap(), 507 "--port-path", 508 &port_path, 509 ]) 510 .env_remove("RUST_LOG") 511 .env(dumbvpn::env::LOCAL_ONLY, "1") 512 .env(dumbvpn::env::PUBLIC, "true") 513 .env(dumbvpn::env::IROH_SECRET, &listen_secret.secret_hex) 514 .env(dumbvpn::env::NETWORK_SECRET, TEST_NETWORK_SECRET) 515 .stdout(std::process::Stdio::null()) 516 .stderr(std::process::Stdio::piped()) 517 .spawn() 518 .expect("spawn listen-unix"); 519 520 let listen_stderr = listen_proc.stderr.take().unwrap(); 521 let listen_stderr_thread = drain_stderr(listen_stderr, "listen-unix-stderr"); 522 523 let target = read_node_addr(port_file.path(), &listen_secret.public, TIMEOUT); 524 525 // Launch connect-unix, exposing the client socket. 526 let mut connect_proc = std::process::Command::new(dumbvpn_bin()) 527 .args([ 528 "connect", 529 "unix", 530 "--socket-path", 531 client_sock.to_str().unwrap(), 532 &target.node_id, 533 "--direct-addr", 534 &target.direct_addr, 535 ]) 536 .env_remove("RUST_LOG") 537 .env(dumbvpn::env::LOCAL_ONLY, "1") 538 .env(dumbvpn::env::PUBLIC, "true") 539 .env(dumbvpn::env::IROH_SECRET, &connect_secret.secret_hex) 540 .env(dumbvpn::env::NETWORK_SECRET, TEST_NETWORK_SECRET) 541 .stdout(std::process::Stdio::null()) 542 .stderr(std::process::Stdio::piped()) 543 .spawn() 544 .expect("spawn connect-unix"); 545 546 let connect_stderr = connect_proc.stderr.take().unwrap(); 547 let connect_stderr_thread = drain_stderr(connect_stderr, "connect-unix-stderr"); 548 549 // Wait for connect-unix to create its socket. 550 wait_for_path(&client_sock, Duration::from_secs(5)); 551 552 // Perform the end-to-end exchange. 553 let mut client = UnixStream::connect(&client_sock).expect("connect to client socket"); 554 client 555 .write_all(b"hello from client") 556 .expect("client write"); 557 558 let mut reply = Vec::new(); 559 client.read_to_end(&mut reply).expect("client read"); 560 assert_eq!(&reply, b"hello from unix"); 561 562 // Clean up child processes. 563 listen_proc.kill().ok(); 564 listen_proc.wait().ok(); 565 connect_proc.kill().ok(); 566 connect_proc.wait().ok(); 567 listen_stderr_thread.join().ok(); 568 connect_stderr_thread.join().ok(); 569 } 570 } 571 572 /// Test that list-nodes returns at least the listener's own entry. 573 /// 574 /// Uses listen-tcp so the listener stays alive (no stdin dependency). 575 #[test] 576 fn list_nodes_returns_self() { 577 let tcp_port = free_port(); 578 let host_port = format!("localhost:{tcp_port}"); 579 580 let listen_secret = test_secret(); 581 let query_secret = test_secret(); 582 let port_file = tempfile::NamedTempFile::new().unwrap(); 583 let port_path = port_file.path().to_str().unwrap().to_string(); 584 585 let _listen = test_env( 586 duct::cmd( 587 dumbvpn_bin(), 588 [ 589 "listen", 590 "tcp", 591 "--host", 592 &host_port, 593 "--port-path", 594 &port_path, 595 "--node-name", 596 "mynode", 597 ], 598 ), 599 &listen_secret, 600 ) 601 .stderr_null() 602 .stdout_null() 603 .start() 604 .unwrap(); 605 606 let target = read_node_addr(port_file.path(), &listen_secret.public, TIMEOUT); 607 let mut args = vec!["list", "nodes"]; 608 args.extend(target.connect_args()); 609 610 let output = test_env(duct::cmd(dumbvpn_bin(), &args), &query_secret) 611 .stderr_null() 612 .stdout_capture() 613 .run() 614 .unwrap(); 615 616 let stdout = String::from_utf8(output.stdout).unwrap(); 617 assert!( 618 stdout.contains("mynode"), 619 "expected 'mynode' in output, got: {stdout}" 620 ); 621 }