/ tests / cli.rs
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  }