From b0f7ff5a711e147083d1dbe49eb41eabe12b36c6 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:28:34 +0700 Subject: [PATCH 01/21] feat(server): add protocol_name() to Server trait Adds a human-readable protocol name method to the Server trait, implemented as "QUIC" and "WebSocket" on the respective server types. Co-Authored-By: Claude Sonnet 4.6 --- crates/wallhack/src/server/quic/mod.rs | 4 ++++ crates/wallhack/src/server/server.rs | 3 +++ crates/wallhack/src/server/ws/mod.rs | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/crates/wallhack/src/server/quic/mod.rs b/crates/wallhack/src/server/quic/mod.rs index 684b316b..08c32ac7 100644 --- a/crates/wallhack/src/server/quic/mod.rs +++ b/crates/wallhack/src/server/quic/mod.rs @@ -219,6 +219,10 @@ impl Server for QuicServer { Ok(()) } + fn protocol_name(&self) -> &'static str { + "QUIC" + } + fn fingerprint(&self) -> &str { &self.fingerprint } diff --git a/crates/wallhack/src/server/server.rs b/crates/wallhack/src/server/server.rs index 96f1a3c2..a3a916c0 100644 --- a/crates/wallhack/src/server/server.rs +++ b/crates/wallhack/src/server/server.rs @@ -150,6 +150,9 @@ pub trait Server { ) -> impl std::future::Future>, Self::Error>> + Send; + /// Returns the human-readable protocol name (e.g. "QUIC", "WebSocket"). + fn protocol_name(&self) -> &'static str; + /// Returns the certificate fingerprint for this server. fn fingerprint(&self) -> &str; diff --git a/crates/wallhack/src/server/ws/mod.rs b/crates/wallhack/src/server/ws/mod.rs index 612a53d7..2f5faabf 100644 --- a/crates/wallhack/src/server/ws/mod.rs +++ b/crates/wallhack/src/server/ws/mod.rs @@ -293,6 +293,10 @@ impl Server for WsServer { Ok(()) } + fn protocol_name(&self) -> &'static str { + "WebSocket" + } + fn fingerprint(&self) -> &str { &self.fingerprint } From 15da4d5e2c90449bca29691a543c3c8b0fe9f0a5 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:28:44 +0700 Subject: [PATCH 02/21] fix(version): split print_version into short and verbose Default --version prints one line: "wallhack ". Full build metadata (time, git hash, features) is behind --verbose. Co-Authored-By: Claude Sonnet 4.6 --- crates/cli/src/version.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/version.rs b/crates/cli/src/version.rs index c040d148..d8af85f0 100644 --- a/crates/cli/src/version.rs +++ b/crates/cli/src/version.rs @@ -6,8 +6,13 @@ pub mod built_info { include!(concat!(env!("OUT_DIR"), "/built.rs")); } -/// Print version information and exit. -pub fn print_version() { +/// Print short version string and exit (`wallhack `). +pub fn print_version_short() { + println!("wallhack {}", built_info::PKG_VERSION); +} + +/// Print full version information with build metadata. +pub fn print_version_verbose() { println!("wallhack {}", built_info::PKG_VERSION); println!("Built: {}", built_info::BUILT_TIME_UTC); From e4a3696ea6a9afa1e7fd193ae1738ef90013e88e Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:28:49 +0700 Subject: [PATCH 03/21] fix(output): skip ANSI colour codes when output is not a terminal format_level() now returns the plain level string (e.g. "[+]") when use_color is false, avoiding escape codes in piped or non-TTY output. Co-Authored-By: Claude Sonnet 4.6 --- crates/cli/src/output.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index 616e84d9..ad445d60 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -59,6 +59,9 @@ impl Output { } fn format_level(&self, level: Level) -> String { + if !self.use_color { + return format!("{level}"); + } match level { Level::Info => format!( "{style}{:6}{style:#}", From 1a97b55db0d4e9a352dce4b6574dd2d58c229320 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:28:54 +0700 Subject: [PATCH 04/21] feat(cli): add --name/-n flag to entry and relay; share generate_node_name() Entry and relay now accept --name/-n (exit already had it). All three node types share a single generate_node_name() helper producing a random 8-char hex ID, replacing the inline logic that was duplicated in ExitCommand. Co-Authored-By: Claude Sonnet 4.6 --- crates/cli/src/cli.rs | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 125828ea..45fcf689 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -97,6 +97,10 @@ pub enum Command { #[derive(FromArgs, Debug)] #[argh(subcommand, name = "entry")] pub struct EntryCommand { + /// name for this node; used for identification (random if omitted) + #[argh(option, short = 'n')] + pub name: Option, + /// listen address for incoming connections (e.g. ":6565") #[argh(option, short = 'l')] pub listen: Option, @@ -151,6 +155,10 @@ pub struct ExitCommand { #[derive(FromArgs, Debug)] #[argh(subcommand, name = "relay")] pub struct RelayCommand { + /// node name (default: random 8-char hex) + #[argh(option, short = 'n')] + pub name: Option, + /// listen address for relay connections (e.g. ":6565") #[argh(option, short = 'l')] pub listen: Option, @@ -164,6 +172,14 @@ pub struct RelayCommand { pub accept_fingerprint: Option, } +/// Generate a random node name (8-character hex ID). +fn generate_node_name() -> String { + use rand::Rng; + let mut rng = rand::rng(); + let id: u32 = rng.random(); + format!("{id:08x}") +} + // ============================================================================ // Transport direction // ============================================================================ @@ -183,6 +199,12 @@ pub enum TransportDir { } impl EntryCommand { + /// Returns the node name, generating a random one if not specified. + #[must_use] + pub fn name(&self) -> String { + self.name.clone().unwrap_or_else(generate_node_name) + } + /// Resolve the transport direction. /// /// Defaults to listening on the default port when neither flag is provided. @@ -238,16 +260,17 @@ impl ExitCommand { /// Returns the peer name, generating a random one if not specified. #[must_use] pub fn name(&self) -> String { - self.name.clone().unwrap_or_else(|| { - use rand::Rng; - let mut rng = rand::rng(); - let id: u32 = rng.random(); - format!("{id:08x}") - }) + self.name.clone().unwrap_or_else(generate_node_name) } } impl RelayCommand { + /// Returns the node name, generating a random one if not specified. + #[must_use] + pub fn name(&self) -> String { + self.name.clone().unwrap_or_else(generate_node_name) + } + /// Resolve both transport directions. /// /// Relay requires **both** `--listen` and `--connect`. From 5dd9bf1a87f38ab12c10b5803e71166318a88d63 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:29:15 +0700 Subject: [PATCH 05/21] fix(startup): reduce noise, wire --verbose to version, initialise colour early - Remove redundant "Starting as node" log lines (each run() now prints its own wallhack header) - --version now dispatches to print_version_short or print_version_verbose - Colour output is initialised at startup based on stderr IsTerminal - Add name: None to default EntryCommand struct literal Co-Authored-By: Claude Sonnet 4.6 --- crates/cli/src/bin/wallhack.rs | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/cli/src/bin/wallhack.rs b/crates/cli/src/bin/wallhack.rs index f5a060ca..07e2ed6d 100644 --- a/crates/cli/src/bin/wallhack.rs +++ b/crates/cli/src/bin/wallhack.rs @@ -8,6 +8,8 @@ //! wallhack exit --listen :443 # Exit, reverse tunnel //! wallhack relay --connect upstream:443 --listen :6565 # Relay +use std::io::IsTerminal; + use anyhow::Result; use cli::{Command, EntryCommand, parse_cli, run_entry, run_exit, run_relay}; use tracing::level_filters::LevelFilter; @@ -16,9 +18,20 @@ use tracing::level_filters::LevelFilter; async fn main() -> Result<()> { let cli = parse_cli(); + // Initialize output config: enable colour only when stderr is a terminal. + cli::output::initialize_output_config( + cli::output::OutputFormat::Plain, + cli::OutputStyles::default(), + std::io::stderr().is_terminal(), + ); + // Handle --version flag if cli.version { - cli::version::print_version(); + if cli.verbose { + cli::version::print_version_verbose(); + } else { + cli::version::print_version_short(); + } return Ok(()); } @@ -28,21 +41,13 @@ async fn main() -> Result<()> { check_entropy_ready(); match &cli.command { - Some(Command::Entry(cmd)) => { - cli::info!("Starting as entry node"); - run_entry(&cli, cmd).await - } - Some(Command::Relay(cmd)) => { - cli::info!("Starting as relay node"); - run_relay(&cli, cmd).await - } - Some(Command::Exit(cmd)) => { - cli::info!("Starting as exit node"); - run_exit(&cli, cmd).await - } + Some(Command::Entry(cmd)) => run_entry(&cli, cmd).await, + Some(Command::Relay(cmd)) => run_relay(&cli, cmd).await, + Some(Command::Exit(cmd)) => run_exit(&cli, cmd).await, None => { // Default: entry node listening on default port let cmd = EntryCommand { + name: None, listen: None, connect: None, api: None, From 1c16b44c9ef5d427fba93c4dd2becf44d93e13fc Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:29:22 +0700 Subject: [PATCH 06/21] fix(relay): add startup header, tighten DNS logging, demote retry to warn - Print wallhack header at startup (consistent with entry/exit) - Show "Connecting to ..." before DNS resolve - Show "Resolved as " only when DNS was actually performed - Demote retry log from info to warn - Remove redundant "Connected to upstream" messages Co-Authored-By: Claude Sonnet 4.6 --- crates/cli/src/relay.rs | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/crates/cli/src/relay.rs b/crates/cli/src/relay.rs index 25f03974..5ae57c1d 100644 --- a/crates/cli/src/relay.rs +++ b/crates/cli/src/relay.rs @@ -38,6 +38,13 @@ const MAX_RETRY_DELAY: Duration = Duration::from_secs(30); /// /// Returns error if server fails (connection errors are retried). pub async fn run(global: &WallhackCli, cmd: &RelayCommand) -> Result<()> { + crate::repl_common::mark_started(); + let name = cmd.name(); + crate::info!( + "wallhack {} {name}", + crate::version::built_info::PKG_VERSION + ); + let (connect_spec, listen_spec) = cmd.transport().map_err(|e| anyhow::anyhow!("{e}"))?; // Parse listen address @@ -54,22 +61,20 @@ pub async fn run(global: &WallhackCli, cmd: &RelayCommand) -> Result<()> { routes: None, }; - // Resolve upstream target - crate::info!("Resolving upstream: {}", connect_spec.addr); + crate::info!("Connecting to {}...", connect_spec.addr); let resolvable = crate::dns::ResolvableAddress::from_str(&connect_spec.addr)?; + tracing::debug!("Resolving {}...", connect_spec.addr); let dns_server = global .dns .as_ref() .map(|s| crate::dns::parse_str_to_addr(s)) .transpose()?; + let is_hostname = resolvable.hostname.parse::().is_err(); let upstream_addr = crate::dns::resolve(resolvable, dns_server).await?; - - crate::info!( - "Connecting to upstream: {} ({:?})", - upstream_addr, - connect_spec.protocol - ); + if is_hostname { + crate::info!("Resolved {} as {}", connect_spec.addr, upstream_addr); + } let psk = global.resolve_psk(); @@ -85,7 +90,6 @@ pub async fn run(global: &WallhackCli, cmd: &RelayCommand) -> Result<()> { ) .await?; let (upstream_instr, upstream_resp) = upstream_client.channels().clone(); - crate::info!("Connected to upstream (QUIC)"); run_downstream( global, &listen_spec, @@ -112,7 +116,6 @@ pub async fn run(global: &WallhackCli, cmd: &RelayCommand) -> Result<()> { ) .await?; let (upstream_instr, upstream_resp) = upstream_client.channels().clone(); - crate::info!("Connected to upstream (WebSocket)"); run_downstream( global, &listen_spec, @@ -251,7 +254,7 @@ async fn connect_quic_upstream( e, retry_delay ); - crate::info!("Connection failed: {e}, retrying in {retry_delay:?}..."); + crate::warn!("Connection failed: {e}, retrying in {retry_delay:?}..."); tokio::time::sleep(retry_delay).await; retry_delay = (retry_delay * 2).min(MAX_RETRY_DELAY); } @@ -302,7 +305,7 @@ async fn connect_ws_upstream( e, retry_delay ); - crate::info!("Connection failed: {e}, retrying in {retry_delay:?}..."); + crate::warn!("Connection failed: {e}, retrying in {retry_delay:?}..."); tokio::time::sleep(retry_delay).await; retry_delay = (retry_delay * 2).min(MAX_RETRY_DELAY); } @@ -320,7 +323,7 @@ async fn run_quic_downstream( ) -> Result<()> { let server_config = build_server_config(global, addr); let mut server = server::quic::QuicServer::try_new(server_config, server_options)?; - crate::info!("Listening on {} (QUIC/UDP)", server.local_addr()?); + crate::info!("Listening on {} (QUIC)", server.local_addr()?); loop { match server.accept(NodeRole::Relay).await { @@ -352,7 +355,7 @@ async fn run_ws_downstream( let server_config = build_server_config(global, addr); let mut server = WsServer::try_new(server_config, server_options)?; - crate::info!("Listening on {} (WebSocket/TCP)", server.local_addr()?); + crate::info!("Listening on {} (WebSocket)", server.local_addr()?); loop { match server.accept(NodeRole::Relay).await { From c5418ac755b705d471d8a83263b7836a4d16afcb Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:29:27 +0700 Subject: [PATCH 07/21] feat(repl-common): add PrintMsg/DoneGuard, uptime(), and unified print_help() - PrintMsg enum (Text/Done) and DoneGuard RAII for readline output sync - uptime() replaces inline print_ping() which mixed version and uptime - print_version_info() prints version only (uptime moved to info command) - Unified print_help() replaces separate entry/exit help functions; command set and descriptions are now identical across all node types Co-Authored-By: Claude Sonnet 4.6 --- crates/cli/src/repl_common.rs | 87 ++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 12 deletions(-) diff --git a/crates/cli/src/repl_common.rs b/crates/cli/src/repl_common.rs index c58c895a..5e1c3d0e 100644 --- a/crates/cli/src/repl_common.rs +++ b/crates/cli/src/repl_common.rs @@ -15,33 +15,83 @@ pub fn mark_started() { NODE_STARTED_AT.get_or_init(Instant::now); } -/// Print version and uptime. -pub fn print_ping(printer: &Printer) { - let uptime = NODE_STARTED_AT +/// Returns the current uptime as a formatted string. +#[must_use] +pub fn uptime() -> String { + NODE_STARTED_AT .get() - .map_or_else(|| "unknown".to_string(), |t| format_duration(t.elapsed())); - printer.print(format!( - "wallhack {} - uptime: {uptime}", - crate::version::built_info::PKG_VERSION - )); + .map_or_else(|| "unknown".to_string(), |t| format_duration(t.elapsed())) +} + +/// Message type for the print channel, allowing commands to signal completion. +pub enum PrintMsg { + /// A line of text to display. + Text(String), + /// Signals that the current REPL command has finished printing. + Done, +} + +/// Print just the version (used by the `version` REPL command). +pub fn print_version_info(printer: &Printer) { + printer.print(crate::version::built_info::PKG_VERSION); +} + +/// Print the unified help text (identical on all node types). +pub fn print_help(printer: &Printer) { + use std::io::Write as _; + let mut tw = tabwriter::TabWriter::new(vec![]).padding(2); + let _ = writeln!(tw, "Available commands:"); + let _ = writeln!(tw, " version"); + let _ = writeln!(tw, " info\tNode state (role, listen address)"); + let _ = writeln!(tw, " peers\tList connected peers"); + let _ = writeln!( + tw, + " ping [peer]\tPing a peer (optional if only one connected)" + ); + let _ = writeln!(tw, " stats\tTraffic statistics"); + let _ = writeln!( + tw, + " route add [via ]\tAdd a route (peer optional if only one connected)" + ); + let _ = writeln!(tw, " route del \tRemove a route"); + let _ = writeln!(tw, " route list\tList all routes"); + let _ = writeln!(tw, " connect \tConnect to a peer"); + let _ = writeln!(tw, " listen [addr]\tStart listening for peers"); + let _ = writeln!(tw, " disconnect [peer]\tDisconnect a peer"); + let _ = writeln!(tw, " help\tShow this help"); + let _ = writeln!(tw, " quit\tExit wallhack"); + let _ = tw.flush(); + let buf = tw.into_inner().unwrap_or_default(); + let output = String::from_utf8_lossy(&buf); + for line in output.trim_end().lines() { + printer.print(line.trim_end()); + } } /// Wrapper for printing to terminal without disrupting readline. #[derive(Clone)] pub struct Printer { - tx: mpsc::UnboundedSender, + tx: mpsc::UnboundedSender, } impl Printer { /// Create a new printer with the given channel. #[must_use] - pub fn new(tx: mpsc::UnboundedSender) -> Self { + pub fn new(tx: mpsc::UnboundedSender) -> Self { Self { tx } } /// Print a message (async-safe). pub fn print(&self, msg: impl Into) { - let _ = self.tx.send(msg.into()); + let _ = self.tx.send(PrintMsg::Text(msg.into())); + } + + /// Signal that the current REPL command has finished producing output. + /// + /// This is consumed by the readline thread to know all responses are queued + /// in `ExternalPrinter` before it draws the next prompt. + pub fn done(&self) { + let _ = self.tx.send(PrintMsg::Done); } /// Print an error message using the standard output formatting (readline-safe). @@ -65,7 +115,20 @@ impl Printer { Ok(config) => config.format_message(&crate::output::StatusMessage { level, message }), Err(_) => format!("{level} {message}"), }; - let _ = self.tx.send(formatted); + let _ = self.tx.send(PrintMsg::Text(formatted)); + } +} + +/// RAII guard that calls [`Printer::done`] when dropped. +/// +/// Place at the top of each REPL command dispatch arm so that `continue`, +/// `break`, and early `return` all reliably signal command completion to the +/// readline thread. +pub struct DoneGuard<'a>(pub &'a Printer); + +impl Drop for DoneGuard<'_> { + fn drop(&mut self) { + self.0.done(); } } From 29d53ac6b7ea31cd50276708af910631f495de14 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:29:33 +0700 Subject: [PATCH 08/21] feat(entry): startup header, REPL unification, DoneGuard output sync - Print wallhack header at startup - Switch print channel to UnboundedSender; drain until Done before drawing next readline prompt - Add DoneGuard at REPL dispatch site to cover all exit paths - Replace print_entry_help with repl_common::print_help - Fix connect/listen help descriptions (were "Not available on entry nodes") Co-Authored-By: Claude Sonnet 4.6 --- crates/cli/src/entry.rs | 317 ++++++++++++++++++++++++---------------- 1 file changed, 187 insertions(+), 130 deletions(-) diff --git a/crates/cli/src/entry.rs b/crates/cli/src/entry.rs index ac9ee76d..25e5c75d 100644 --- a/crates/cli/src/entry.rs +++ b/crates/cli/src/entry.rs @@ -8,10 +8,7 @@ use std::{ collections::HashMap, io::{IsTerminal, Write}, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, + sync::{Arc, atomic::Ordering}, }; use anyhow::{Context, Result}; @@ -101,7 +98,10 @@ async fn create_tun_with_retry(name: String) -> anyhow::Result { #[cfg(feature = "readline")] use rustyline::ExternalPrinter; -use crate::repl_common::{PeerRow, Printer, format_duration, print_peer_table, print_ping}; +use crate::repl_common::{ + DoneGuard, PeerRow, PrintMsg, Printer, format_duration, print_peer_table, print_version_info, + uptime, +}; /// Run as an entry node with interactive REPL. /// @@ -114,6 +114,11 @@ use crate::repl_common::{PeerRow, Printer, format_duration, print_peer_table, pr /// Returns error if server or client setup fails. pub async fn run(global: &WallhackCli, cmd: &EntryCommand) -> Result<()> { crate::repl_common::mark_started(); + let name = cmd.name(); + crate::info!( + "wallhack {} {name}", + crate::version::built_info::PKG_VERSION + ); let transport = cmd.transport().map_err(|e| anyhow::anyhow!("{e}"))?; let sessions = SessionManager::default(); let metrics = Arc::new(Metrics::default()); @@ -174,23 +179,7 @@ async fn run_entry_listen( { let server = wallhack::server::quic::QuicServer::try_new(server_config, server_options)?; - crate::info!("Listening on {} (QUIC/UDP)", server.local_addr()?); - crate::info!("Certificate fingerprint: {}", server.fingerprint()); - if server.psk().is_none() { - crate::warn!( - "No authentication configured. Use --psk to require authentication." - ); - } - run_entry_server( - server, - metrics, - peers, - routes, - sessions, - cmd.max_peers, - cmd.fast, - ) - .await + start_entry_server(server, metrics, peers, routes, sessions, cmd).await } #[cfg(not(feature = "quic"))] { @@ -202,23 +191,7 @@ async fn run_entry_listen( { let server = wallhack::server::ws::WsServer::try_new(server_config, server_options)?; - crate::info!("Listening on {} (WebSocket/TCP)", server.local_addr()?); - crate::info!("Certificate fingerprint: {}", server.fingerprint()); - if server.psk().is_none() { - crate::warn!( - "No authentication configured. Use --psk to require authentication." - ); - } - run_entry_server( - server, - metrics, - peers, - routes, - sessions, - cmd.max_peers, - cmd.fast, - ) - .await + start_entry_server(server, metrics, peers, routes, sessions, cmd).await } #[cfg(not(feature = "websocket"))] { @@ -230,6 +203,41 @@ async fn run_entry_listen( } } +/// Announce the server and run the entry server loop. +async fn start_entry_server( + server: S, + metrics: Arc, + peers: Arc, + routes: SharedRouteTable, + sessions: SessionManager, + cmd: &EntryCommand, +) -> Result<()> +where + S::Error: std::error::Error + Send + Sync + 'static, + S::Transport: Send + Sync + 'static, +{ + let local_addr = server.local_addr()?; + let proto = server.protocol_name(); + crate::info!("Listening on {local_addr} ({proto})"); + crate::info!("Certificate fingerprint: {}", server.fingerprint()); + if server.psk().is_none() { + crate::warn!("No authentication configured. Use --psk to require authentication."); + } + run_entry_server( + server, + metrics, + peers, + routes, + sessions, + EntryListenOptions { + max_peers: cmd.max_peers, + fast_mode: cmd.fast, + listen_info: format!("role: entry\nlisten: {local_addr} ({proto})"), + }, + ) + .await +} + /// Run entry node in connect mode (reverse tunnel). /// /// Entry connects to a remote peer but still creates TUN and runs REPL. @@ -250,7 +258,7 @@ async fn run_entry_connect( // Used only with the `api` feature. let _ = (&cmd, &peers); - crate::info!("Resolving {}", spec.addr); + crate::info!("Connecting to {}...", spec.addr); let resolvable = crate::dns::ResolvableAddress::from_str(&spec.addr)?; let dns_server = global .dns @@ -373,6 +381,12 @@ async fn handle_entry_connect_result, + fast_mode: bool, + listen_info: String, +} + /// Generic entry server loop that works with any `Server` implementation. #[allow(clippy::too_many_lines)] async fn run_entry_server( @@ -381,13 +395,17 @@ async fn run_entry_server( peers: Arc, routes: SharedRouteTable, sessions: SessionManager, - max_peers: Option, - fast_mode: bool, + options: EntryListenOptions, ) -> Result<()> where S::Error: std::error::Error + Send + Sync + 'static, S::Transport: Send + Sync + 'static, { + let EntryListenOptions { + max_peers, + fast_mode, + listen_info, + } = options; let server_psk = server.psk().map(String::from); let peer_semaphore = Arc::new(tokio::sync::Semaphore::new( max_peers.unwrap_or(tokio::sync::Semaphore::MAX_PERMITS), @@ -397,7 +415,7 @@ where let (repl_tx, repl_rx) = mpsc::channel::(16); // Channel for async prints (async loop -> input thread) - let (print_tx, print_rx) = mpsc::unbounded_channel::(); + let (print_tx, print_rx) = mpsc::unbounded_channel::(); let printer = Printer::new(print_tx); // Only spawn REPL if stdin is a terminal (skip in headless/Docker mode) @@ -410,16 +428,12 @@ where }); Some(repl_rx) } else { - crate::info!("Running in headless mode (no REPL)."); - // Drop the sender so REPL doesn't block + // Headless mode — drop REPL channels so senders don't block drop(repl_tx); drop(print_rx); None }; - // Connection counter for tracking - let next_conn_id = AtomicU64::new(1); - // Main loop: handle both server accepts and REPL commands loop { tokio::select! { @@ -433,7 +447,6 @@ where continue; }; - let conn_id = next_conn_id.fetch_add(1, Ordering::Relaxed); let conn_metrics = accept_result.metrics(); let conn_printer = printer.clone(); let conn_sessions = sessions.clone(); @@ -444,10 +457,8 @@ where .exit_hello() .map_or_else(|| peer_addr.clone(), |h| h.name.clone()); - printer.info(format!("Connection #{conn_id} from {peer_addr}")); - - // Register peer in the registry - conn_peers.register(peer.clone(), peer_addr, NodeRole::Exit); + // Register peer in the registry (clone peer_addr — also passed to handle_connection) + conn_peers.register(peer.clone(), peer_addr.clone(), NodeRole::Exit); // Create ping channel for this peer let mut ping_rx = conn_peers.register_ping_channel(&peer); @@ -458,7 +469,7 @@ where tokio::spawn(async move { // Hold the permit for the lifetime of this connection let _permit = permit; - let result = handle_connection(conn_metrics, accept_result, conn_sessions.clone(), &mut ping_rx, &transport, &conn_peers, conn_psk, fast_mode).await; + let result = handle_connection(conn_metrics, accept_result, conn_sessions.clone(), &mut ping_rx, &transport, &conn_peers, conn_psk, fast_mode, peer_addr, conn_printer.clone()).await; // Unregister peer when connection closes conn_peers.unregister(&peer); // Clean up routes for this peer @@ -469,18 +480,18 @@ where } } if !removed_routes.is_empty() { - conn_printer.print(format!( + conn_printer.info(format!( "Removed {} route(s) for disconnected peer {peer}", removed_routes.len() )); } match result { - Ok(tun_name) => { - conn_printer.print(format!("Connection #{conn_id} closed (tun: {tun_name})")); + Ok(_tun_name) => { + conn_printer.info(format!("Peer disconnected: {peer}")); } Err(e) => { - tracing::debug!("Connection #{} error: {}", conn_id, e); - conn_printer.print(format!("Connection #{conn_id} error: {e}")); + tracing::debug!("Connection error for {}: {}", peer, e); + conn_printer.error(format!("Peer {peer} disconnected with error: {e}")); } } }); @@ -502,16 +513,47 @@ where None => std::future::pending().await, } } => { + let _done = DoneGuard(&printer); match cmd { Some(ReplCommand::Quit) | None => { printer.print("Shutting down..."); break; } - Some(ReplCommand::Ping) => { - print_ping(&printer); + Some(ReplCommand::Version) => { + print_version_info(&printer); + } + Some(ReplCommand::Info) => { + for line in listen_info.lines() { + printer.print(line); + } + printer.print(format!("uptime: {}", uptime())); + } + Some(ReplCommand::Ping(peer_opt)) => { let peer_names = peers.peer_names(); - if !peer_names.is_empty() { - for id in &peer_names { + if peer_names.is_empty() { + printer.print("No connected peers."); + } else { + let targets: Vec = match peer_opt { + Some(ref name) => { + if peer_names.contains(name) { + vec![name.clone()] + } else { + printer.print(format!("Peer not found: {name}")); + continue; + } + } + None => { + if peer_names.len() == 1 { + peer_names.clone() + } else { + printer.print( + "Multiple peers connected; specify one: ping ", + ); + continue; + } + } + }; + for id in &targets { match peers.ping_peer(id).await { Ok(ms) => printer.print(format!(" {id}: {ms:.3}ms")), Err(e) => printer.print(format!(" {id}: ping failed ({e})")), @@ -525,6 +567,9 @@ where Some(ReplCommand::Peers) => { print_peers(&peers, &sessions, &printer); } + Some(ReplCommand::NotApplicable(msg)) => { + printer.print(msg); + } Some(ReplCommand::RouteAdd(cidr, peer_opt)) => { let peer = if let Some(p) = peer_opt { p @@ -533,7 +578,7 @@ where if let [name] = names.as_slice() { name.clone() } else if names.is_empty() { - printer.print("No peers connected."); + printer.print("No connected peers."); continue; } else { printer.print( @@ -550,14 +595,29 @@ where Some(ReplCommand::RouteList) => { handle_route_list(&routes, &sessions, &printer); } - Some(ReplCommand::Disconnect(peer)) => { + Some(ReplCommand::Disconnect(peer_opt)) => { + let peer_names = peers.peer_names(); + let peer = match peer_opt { + Some(p) => p, + None if peer_names.is_empty() => { + printer.print("No connected peers."); + continue; + } + None if peer_names.len() == 1 => peer_names.into_iter().next().unwrap(), + None => { + printer.print("Multiple peers connected; specify one: disconnect "); + continue; + } + }; handle_disconnect(&peer, &peers, &printer); } Some(ReplCommand::Help) => { - print_help(&printer); + crate::repl_common::print_help(&printer); } Some(ReplCommand::Unknown(cmd)) => { - printer.print(format!("Unknown command: {cmd}. Type 'help' for available commands.")); + printer.print(format!( + "Unknown command: {cmd}. Type 'help' for available commands." + )); } } } @@ -570,13 +630,17 @@ where /// REPL commands that can be sent from the input thread. enum ReplCommand { Quit, - Ping, + Version, + Info, + Ping(Option), Stats, Peers, RouteAdd(String, Option), RouteRemove(String), RouteList, - Disconnect(String), + Disconnect(Option), + /// Recognised commands that don't apply to entry nodes. + NotApplicable(&'static str), Help, Unknown(String), } @@ -586,7 +650,7 @@ enum ReplCommand { fn run_repl_input( tx: &mpsc::Sender, _metrics: Arc, - mut print_rx: mpsc::UnboundedReceiver, + mut print_rx: mpsc::UnboundedReceiver, ) { let mut rl = match rustyline::DefaultEditor::new() { Ok(rl) => rl, @@ -597,18 +661,28 @@ fn run_repl_input( } }; - // Create external printer for async output, falling back to println if - // unavailable - let mut printer = rl.create_external_printer().ok(); + // External printer: used to display messages above the active prompt. + let mut ep = rl.create_external_printer().ok(); + + // done channel: print thread signals readline thread once each command's + // output has been queued in ExternalPrinter. + let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); - // Spawn thread to handle print requests + // Print thread: receives Text messages (queues into ExternalPrinter so they + // appear above the prompt) and Done messages (signals readline thread). std::thread::spawn(move || { while let Some(msg) = print_rx.blocking_recv() { - if let Some(ref mut p) = printer { - let _ = p.print(msg); - } else { - // Fallback if external printer couldn't be created (e.g. non-TTY env) - println!("{msg}"); + match msg { + PrintMsg::Text(s) => { + if let Some(ref mut p) = ep { + let _ = p.print(s); + } else { + println!("{s}"); + } + } + PrintMsg::Done => { + let _ = done_tx.send(()); + } } } }); @@ -628,6 +702,10 @@ fn run_repl_input( if tx.blocking_send(cmd).is_err() || is_quit { break; } + // Wait for DoneGuard signal: all command response Text messages + // are now queued in ExternalPrinter. readline() will display them + // before drawing the next prompt. + let _ = done_rx.recv_timeout(std::time::Duration::from_millis(500)); } Err(rustyline::error::ReadlineError::Interrupted) => { // continue; @@ -645,18 +723,10 @@ fn run_repl_input( fn run_repl_input( tx: &mpsc::Sender, _metrics: Arc, - mut print_rx: mpsc::UnboundedReceiver, + mut print_rx: mpsc::UnboundedReceiver, ) { use std::io::{BufRead, Write}; - // Spawn thread to handle print requests (just println without readline - // coordination) - std::thread::spawn(move || { - while let Some(msg) = print_rx.blocking_recv() { - println!("{msg}"); - } - }); - let stdin = std::io::stdin(); let mut stdout = std::io::stdout(); @@ -666,8 +736,7 @@ fn run_repl_input( let mut line = String::new(); match stdin.lock().read_line(&mut line) { - Ok(0) => { - // EOF + Ok(0) | Err(_) => { let _ = tx.blocking_send(ReplCommand::Quit); break; } @@ -682,10 +751,10 @@ fn run_repl_input( if tx.blocking_send(cmd).is_err() || is_quit { break; } - } - Err(_) => { - let _ = tx.blocking_send(ReplCommand::Quit); - break; + // Drain response messages synchronously before showing next prompt. + while let Some(PrintMsg::Text(s)) = print_rx.blocking_recv() { + println!("{s}"); + } } } } @@ -698,27 +767,21 @@ fn parse_repl_command(line: &str) -> ReplCommand { let arg = parts.next().map(String::from); match cmd.as_str() { - "quit" | "exit" | "q" => ReplCommand::Quit, - "ping" | "p" => ReplCommand::Ping, - "stats" | "s" => ReplCommand::Stats, - "peers" | "sessions" | "tuns" | "t" => ReplCommand::Peers, + "quit" => ReplCommand::Quit, + "version" => ReplCommand::Version, + "info" => ReplCommand::Info, + "ping" => ReplCommand::Ping(arg), + "stats" => ReplCommand::Stats, + "peers" => ReplCommand::Peers, "route" => parse_route_subcommand(arg.as_deref(), &mut parts), - "ip" => match arg.as_deref() { - Some("route") => { - let sub = parts.next().map(String::from); - parse_route_subcommand(sub.as_deref(), &mut parts) - } - _ => ReplCommand::Unknown("ip route ...".to_string()), - }, - "routes" => ReplCommand::RouteList, - "disconnect" | "kick" | "kill" => { - if let Some(peer) = arg { - ReplCommand::Disconnect(peer) - } else { - ReplCommand::Unknown("disconnect ".to_string()) - } + "disconnect" => ReplCommand::Disconnect(arg), + "connect" => { + ReplCommand::NotApplicable("connect is only available for exit and relay nodes.") + } + "listen" => { + ReplCommand::NotApplicable("listen is only available for exit and relay nodes.") } - "help" | "?" => ReplCommand::Help, + "help" => ReplCommand::Help, _ => ReplCommand::Unknown(line.to_string()), } } @@ -910,21 +973,6 @@ fn handle_disconnect(peer: &str, peers: &Arc, printer: &Printer) { } } -fn print_help(printer: &Printer) { - printer.print("Available commands:"); - printer.print(" ping, p - Show version and uptime"); - printer.print(" stats, s - Show traffic statistics"); - printer.print(" peers - List connected peers and sessions"); - printer.print( - " route add [via ] - Add a route (peer optional if only one connected)", - ); - printer.print(" route del - Remove a route"); - printer.print(" route list, routes, ip route - List all routes"); - printer.print(" disconnect - Disconnect a peer"); - printer.print(" help, ? - Show this help message"); - printer.print(" quit, q - Exit wallhack"); -} - /// Apply an OS-level route via `ip route add`. fn apply_os_route(cidr: &str, dev: &str) -> Result<(), String> { match std::process::Command::new("ip") @@ -982,6 +1030,8 @@ async fn handle_connection( peers: &Arc, server_psk: Option, fast_mode: bool, + peer_addr: String, + printer: Printer, ) -> Result { // Get ExitNodeHello directly from accept result (already read during accept) let peer = if let Some(hello) = accept_result.take_exit_hello() { @@ -997,10 +1047,10 @@ async fn handle_connection( } } - crate::info!("Exit node identified: {} (v{})", hello.name, hello.version); + tracing::debug!("Peer {} identified (v{})", hello.name, hello.version); Some(hello.name) } else { - crate::verbose!("No ExitNodeHello received, using anonymous session"); + tracing::debug!("No ExitNodeHello received, using anonymous session"); None }; @@ -1056,6 +1106,13 @@ async fn handle_connection( }; let actor = create_tun_with_retry(name.clone()).await?; + + // Announce connection after TUN is created — collapses accept + hello into one message + let peer_display = peer.as_deref().unwrap_or(&peer_addr); + printer.info(format!( + "Peer connected: {peer_display} ({peer_addr}, tun: {name})" + )); + let responses_rx = responses_tx.subscribe(); drop(responses_tx); // background data-in task holds its own clone; drop ours so RecvError::Closed can fire let (manager, _syn_proxy_state) = ConnectionManager::new( From caa250c1c82401f94bf4a488572515b655e859a8 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:29:39 +0700 Subject: [PATCH 09/21] feat(exit): startup header, REPL unification, drop state:, DoneGuard sync - Print wallhack header at startup - Switch print channel to UnboundedSender; readline path waits for Done before drawing next prompt; non-readline path drains directly - Add DoneGuard at all REPL dispatch sites (7 call sites) - Drop state: field from info output; presence/absence of connect:/listen: lines conveys the same information without inventing a state abstraction - Replace print_exit_help with repl_common::print_help - Route commands available on all node types (consistent CLI) Co-Authored-By: Claude Sonnet 4.6 --- crates/cli/src/exit.rs | 601 +++++++++++++++++++++++++---------------- 1 file changed, 366 insertions(+), 235 deletions(-) diff --git a/crates/cli/src/exit.rs b/crates/cli/src/exit.rs index bed98303..03f6091a 100644 --- a/crates/cli/src/exit.rs +++ b/crates/cli/src/exit.rs @@ -56,7 +56,10 @@ const MAX_RETRY_DELAY: Duration = Duration::from_secs(30); /// For slower protocols, streams queue on entry node (backpressure). const UDP_RESPONSE_TIMEOUT: Duration = Duration::from_millis(500); -use crate::repl_common::{PeerRow, Printer, format_duration, print_peer_table, print_ping}; +use crate::repl_common::{ + DoneGuard, PeerRow, PrintMsg, Printer, format_duration, print_peer_table, print_version_info, + uptime, +}; #[cfg(feature = "readline")] use rustyline::ExternalPrinter; @@ -64,13 +67,16 @@ use rustyline::ExternalPrinter; /// REPL commands for exit nodes. enum ExitReplCommand { Quit, + Version, + Info, Ping, Stats, - Status, Peers, Connect(String), Listen(String), Disconnect, + /// Route commands — not applicable to exit nodes. + RouteCmd, Help, Unknown(String), } @@ -97,7 +103,7 @@ enum ExitAction { fn setup_exit_repl() -> (Option>, Option) { if crate::repl_common::is_interactive() { let (tx, rx) = mpsc::channel::(16); - let (print_tx, print_rx) = mpsc::unbounded_channel::(); + let (print_tx, print_rx) = mpsc::unbounded_channel::(); let printer = Printer::new(print_tx); crate::info!("Type 'help' for commands, 'quit' to exit."); @@ -108,7 +114,7 @@ fn setup_exit_repl() -> (Option>, Option Result<()> { crate::repl_common::mark_started(); let transport = cmd.transport().map_err(|e| anyhow::anyhow!("{e}"))?; let name = cmd.name(); + crate::info!( + "wallhack {} {name}", + crate::version::built_info::PKG_VERSION + ); let metrics = Arc::new(Metrics::default()); let security = SecurityConfig { psk: global.resolve_psk(), @@ -145,10 +155,6 @@ pub async fn run(global: &WallhackCli, cmd: &ExitCommand) -> Result<()> { loop { let result = match (&connect_spec, &listen_spec) { (Some(c), Some(l)) => { - crate::route_info!( - printer.as_ref(), - "Exit node with relay capability as {name}" - ); run_relay_capability_mode( global, &name, @@ -161,7 +167,6 @@ pub async fn run(global: &WallhackCli, cmd: &ExitCommand) -> Result<()> { .await } (Some(c), None) => { - crate::route_info!(printer.as_ref(), "Exit node starting as {name}"); run_connect_mode( global, &name, @@ -174,10 +179,9 @@ pub async fn run(global: &WallhackCli, cmd: &ExitCommand) -> Result<()> { .await } (None, Some(l)) => { - crate::route_info!(printer.as_ref(), "Exit node listening as {name}"); - run_listen_mode(global, l, &metrics, &mut repl_rx, printer.as_ref()).await + run_listen_mode(global, &name, l, &metrics, &mut repl_rx, printer.as_ref()).await } - (None, None) => run_idle_mode(&metrics, &mut repl_rx, printer.as_ref()).await, + (None, None) => run_idle_mode(&name, &metrics, &mut repl_rx, printer.as_ref()).await, }; let action = match result { @@ -222,7 +226,7 @@ async fn run_connect_mode( printer: Option<&Printer>, security: &SecurityConfig, ) -> Result { - crate::route_info!(printer, "Resolving {}", spec.addr); + crate::route_info!(printer, "Connecting to {}...", spec.addr); let resolvable = crate::dns::ResolvableAddress::from_str(&spec.addr)?; let dns_server = global @@ -232,7 +236,6 @@ async fn run_connect_mode( .transpose()?; let endpoint = crate::dns::resolve(resolvable, dns_server).await?; - crate::route_info!(printer, "Resolved as {endpoint:?}"); match spec.protocol { Protocol::Udp => { @@ -261,14 +264,14 @@ async fn run_connect_mode( /// Run with relay capability (both connect and listen). async fn run_relay_capability_mode( global: &WallhackCli, - _name: &str, + name: &str, connect_spec: &crate::cli::AddressSpec, listen_spec: &crate::cli::AddressSpec, metrics: &Arc, repl_rx: &mut Option>, printer: Option<&Printer>, ) -> Result { - crate::route_info!(printer, "Resolving {}", connect_spec.addr); + crate::route_info!(printer, "Connecting to {}...", connect_spec.addr); let resolvable = crate::dns::ResolvableAddress::from_str(&connect_spec.addr)?; let dns_server = global .dns @@ -277,7 +280,6 @@ async fn run_relay_capability_mode( .transpose()?; let peer_addr = crate::dns::resolve(resolvable, dns_server).await?; - crate::route_info!(printer, "Connecting to peer: {peer_addr}"); let listen_addr = parse_listen_addr(&listen_spec.addr)?; @@ -285,8 +287,16 @@ async fn run_relay_capability_mode( Protocol::Udp => { #[cfg(feature = "quic")] { - run_quic_relay_capability(global, peer_addr, listen_addr, metrics, repl_rx, printer) - .await + run_quic_relay_capability( + global, + peer_addr, + listen_addr, + name, + metrics, + repl_rx, + printer, + ) + .await } #[cfg(not(feature = "quic"))] { @@ -296,8 +306,16 @@ async fn run_relay_capability_mode( Protocol::Tcp => { #[cfg(feature = "websocket")] { - run_ws_relay_capability(global, peer_addr, listen_addr, metrics, repl_rx, printer) - .await + run_ws_relay_capability( + global, + peer_addr, + listen_addr, + name, + metrics, + repl_rx, + printer, + ) + .await } #[cfg(not(feature = "websocket"))] { @@ -310,6 +328,7 @@ async fn run_relay_capability_mode( /// Run in listen-only mode (reverse tunnel) with REPL. async fn run_listen_mode( global: &WallhackCli, + node_name: &str, spec: &crate::cli::AddressSpec, metrics: &Arc, repl_rx: &mut Option>, @@ -338,8 +357,8 @@ async fn run_listen_mode( let server = wallhack::server::quic::QuicServer::try_new(server_config, server_options)?; let bound = server.local_addr()?; - crate::route_info!(printer, "Listening on {bound} (QUIC/UDP)"); - run_listen_server_loop(server, metrics, repl_rx, printer, bound).await + crate::route_info!(printer, "Listening on {bound} ({})", server.protocol_name()); + run_listen_server_loop(server, metrics, repl_rx, printer, bound, node_name).await } #[cfg(not(feature = "quic"))] anyhow::bail!("QUIC transport not available (compile with --features quic)") @@ -350,8 +369,8 @@ async fn run_listen_mode( let server = wallhack::server::ws::WsServer::try_new(server_config, server_options)?; let bound = server.local_addr()?; - crate::route_info!(printer, "Listening on {bound} (WebSocket/TCP)"); - run_listen_server_loop(server, metrics, repl_rx, printer, bound).await + crate::route_info!(printer, "Listening on {bound} ({})", server.protocol_name()); + run_listen_server_loop(server, metrics, repl_rx, printer, bound, node_name).await } #[cfg(not(feature = "websocket"))] anyhow::bail!("WebSocket transport not available (compile with --features websocket)") @@ -359,6 +378,86 @@ async fn run_listen_mode( } } +/// Handle a REPL command while in listen mode. +/// +/// Returns `Some(action)` if the command triggers a mode transition, `None` to continue. +fn handle_listen_repl_cmd( + cmd: Option, + printer: Option<&Printer>, + listen_addr: std::net::SocketAddr, + node_name: &str, + metrics: &Arc, +) -> Option { + match cmd { + Some(ExitReplCommand::Quit) | None => Some(ExitAction::Quit), + Some(ExitReplCommand::Version) => { + if let Some(p) = printer { + print_version_info(p); + } + None + } + Some(ExitReplCommand::Info) => { + if let Some(p) = printer { + p.print(format!("role: exit ({node_name})")); + p.print(format!("listen: {listen_addr}")); + p.print(format!("uptime: {}", uptime())); + } + None + } + Some(ExitReplCommand::Connect(addr)) => Some(ExitAction::StartConnect(addr)), + Some(ExitReplCommand::Listen(_)) => { + if let Some(p) = printer { + p.print(format!("Already listening on {listen_addr}.")); + } + None + } + Some(ExitReplCommand::Disconnect) => { + if let Some(p) = printer { + p.print("No connected peers."); + } + None + } + Some(ExitReplCommand::Ping) => { + if let Some(p) = printer { + p.print("Ping not available: no peers connected."); + } + None + } + Some(ExitReplCommand::Stats) => { + if let Some(p) = printer { + print_exit_stats(metrics, p); + } + None + } + Some(ExitReplCommand::Peers) => { + if let Some(p) = printer { + print_peer_table(p, &[]); + } + None + } + Some(ExitReplCommand::RouteCmd) => { + if let Some(p) = printer { + p.print("route commands are only available for entry nodes."); + } + None + } + Some(ExitReplCommand::Help) => { + if let Some(p) = printer { + crate::repl_common::print_help(p); + } + None + } + Some(ExitReplCommand::Unknown(cmd)) => { + if let Some(p) = printer { + p.print(format!( + "Unknown command: {cmd}. Type 'help' for available commands." + )); + } + None + } + } +} + /// Server accept loop with REPL integration for listen-only mode. async fn run_listen_server_loop( mut server: S, @@ -366,6 +465,7 @@ async fn run_listen_server_loop( repl_rx: &mut Option>, printer: Option<&Printer>, listen_addr: std::net::SocketAddr, + node_name: &str, ) -> Result where S::Error: std::error::Error + Send + Sync + 'static, @@ -415,49 +515,11 @@ where None => std::future::pending().await, } } => { - match cmd { - Some(ExitReplCommand::Quit) | None => return Ok(ExitAction::Quit), - Some(ExitReplCommand::Connect(addr)) => return Ok(ExitAction::StartConnect(addr)), - Some(ExitReplCommand::Listen(_)) => { - if let Some(p) = printer { - p.print(format!("Already listening on {listen_addr}")); - } - } - Some(ExitReplCommand::Disconnect) => { - if let Some(p) = printer { - p.print("Not connected to any peer."); - } - } - Some(ExitReplCommand::Ping) => { - if let Some(p) = printer { - print_ping(p); - } - } - Some(ExitReplCommand::Stats) => { - if let Some(p) = printer { - print_exit_stats(metrics, p); - } - } - Some(ExitReplCommand::Status) => { - if let Some(p) = printer { - print_listen_status(p, listen_addr); - } - } - Some(ExitReplCommand::Peers) => { - if let Some(p) = printer { - print_peer_table(p, &[]); - } - } - Some(ExitReplCommand::Help) => { - if let Some(p) = printer { - print_listen_help(p); - } - } - Some(ExitReplCommand::Unknown(cmd)) => { - if let Some(p) = printer { - p.print(format!("Unknown command: {cmd}. Type 'help' for available commands.")); - } - } + let _done = printer.map(DoneGuard); + if let Some(action) = + handle_listen_repl_cmd(cmd, printer, listen_addr, node_name, metrics) + { + return Ok(action); } } } @@ -468,6 +530,7 @@ where /// Run in idle mode (no connection, no listener). async fn run_idle_mode( + node_name: &str, metrics: &Arc, repl_rx: &mut Option>, printer: Option<&Printer>, @@ -485,23 +548,30 @@ async fn run_idle_mode( } }; + let _done = printer.map(DoneGuard); match cmd { Some(ExitReplCommand::Quit) | None => return Ok(ExitAction::Quit), - Some(ExitReplCommand::Connect(addr)) => return Ok(ExitAction::StartConnect(addr)), - Some(ExitReplCommand::Listen(addr)) => return Ok(ExitAction::StartListen(addr)), - Some(ExitReplCommand::Disconnect) => { + Some(ExitReplCommand::Version) => { + if let Some(p) = printer { + print_version_info(p); + } + } + Some(ExitReplCommand::Info) => { if let Some(p) = printer { - p.print("Not connected."); + p.print(format!("role: exit ({node_name})")); + p.print(format!("uptime: {}", uptime())); } } - Some(ExitReplCommand::Status) => { + Some(ExitReplCommand::Connect(addr)) => return Ok(ExitAction::StartConnect(addr)), + Some(ExitReplCommand::Listen(addr)) => return Ok(ExitAction::StartListen(addr)), + Some(ExitReplCommand::Disconnect) => { if let Some(p) = printer { - p.print("Node Status: Idle (not connected, not listening)"); + p.print("No connected peers."); } } Some(ExitReplCommand::Ping) => { if let Some(p) = printer { - print_ping(p); + p.print("Ping not available: no peers connected."); } } Some(ExitReplCommand::Stats) => { @@ -514,9 +584,14 @@ async fn run_idle_mode( print_peer_table(p, &[]); } } + Some(ExitReplCommand::RouteCmd) => { + if let Some(p) = printer { + p.print("route commands are only available for entry nodes."); + } + } Some(ExitReplCommand::Help) => { if let Some(p) = printer { - print_idle_help(p); + crate::repl_common::print_help(p); } } Some(ExitReplCommand::Unknown(cmd)) => { @@ -540,6 +615,7 @@ async fn run_exit_loop( repl_rx: &mut Option>, printer: Option<&Printer>, peer_addr: &str, + node_name: &str, ) -> Result> { crate::route_info!(printer, "Connected to {peer_addr}"); @@ -571,7 +647,7 @@ async fn run_exit_loop( Ok(()) => { tracing::debug!("Connection closed cleanly"); "Connection closed, reconnecting...".into() } Err(e) => { tracing::debug!("Orchestrator error: {}", e); format!("Connection error: {e}, reconnecting...") } }; - if let Some(p) = printer { p.print(msg); } else { crate::info!("{msg}"); } + if let Some(p) = printer { p.warn(msg); } else { crate::warn!("{msg}"); } return Ok(None); } result = &mut stream_fut => { @@ -581,7 +657,7 @@ async fn run_exit_loop( () = &mut disconnect_fut => { tracing::debug!("Connection tasks died - transport disconnected"); let msg = "Transport disconnected, reconnecting..."; - if let Some(p) = printer { p.print(msg); } else { crate::info!("{msg}"); } + if let Some(p) = printer { p.warn(msg); } else { crate::warn!("{msg}"); } return Ok(None); } cmd = async { @@ -590,52 +666,97 @@ async fn run_exit_loop( None => std::future::pending().await, } } => { - match cmd { - Some(ExitReplCommand::Quit) | None => return Ok(Some(ExitAction::Quit)), - Some(ExitReplCommand::Listen(addr)) => return Ok(Some(ExitAction::StartListen(addr))), - Some(ExitReplCommand::Connect(_)) => { - if let Some(p) = printer { - p.print(format!("Already connected to {peer_addr}. Use 'disconnect' first.")); - } - } - Some(ExitReplCommand::Disconnect) => return Ok(Some(ExitAction::StopConnect)), - Some(ExitReplCommand::Peers) => { - if let Some(p) = printer { - let row = exit_peer_row("entry", peer_addr, &format_duration(connected_at.elapsed())); - print_peer_table(p, &[row]); - } - } - Some(ExitReplCommand::Ping) => { - if let Some(p) = printer { - print_ping(p); - } - } - Some(ExitReplCommand::Stats) => { - if let Some(p) = printer { - print_exit_stats(metrics, p); - } - } - Some(ExitReplCommand::Status) => { - if let Some(p) = printer { - print_exit_status(p, true, peer_addr); - } - } - Some(ExitReplCommand::Help) => { - if let Some(p) = printer { - print_connect_help(p); - } - } - Some(ExitReplCommand::Unknown(cmd)) => { - if let Some(p) = printer { - p.print(format!("Unknown command: {cmd}. Type 'help' for available commands.")); - } - } + let _done = printer.map(DoneGuard); + if let Some(action) = handle_connected_repl_cmd( + cmd, printer, peer_addr, node_name, metrics, connected_at, + ) { + return Ok(Some(action)); } } } } } +/// Handle a REPL command while connected to an entry node. +/// +/// Returns `Some(action)` if the command triggers a mode transition, `None` to continue. +fn handle_connected_repl_cmd( + cmd: Option, + printer: Option<&Printer>, + peer_addr: &str, + node_name: &str, + metrics: &Arc, + connected_at: Instant, +) -> Option { + match cmd { + Some(ExitReplCommand::Quit) | None => Some(ExitAction::Quit), + Some(ExitReplCommand::Version) => { + if let Some(p) = printer { + print_version_info(p); + } + None + } + Some(ExitReplCommand::Info) => { + if let Some(p) = printer { + p.print(format!("role: exit ({node_name})")); + p.print(format!("connect: {peer_addr}")); + p.print(format!("uptime: {}", uptime())); + } + None + } + Some(ExitReplCommand::Listen(addr)) => Some(ExitAction::StartListen(addr)), + Some(ExitReplCommand::Connect(_)) => { + if let Some(p) = printer { + p.print(format!( + "Already connected to {peer_addr}. Use 'disconnect' first." + )); + } + None + } + Some(ExitReplCommand::Disconnect) => Some(ExitAction::StopConnect), + Some(ExitReplCommand::Peers) => { + if let Some(p) = printer { + let row = + exit_peer_row("entry", peer_addr, &format_duration(connected_at.elapsed())); + print_peer_table(p, &[row]); + } + None + } + Some(ExitReplCommand::Ping) => { + if let Some(p) = printer { + p.print("Ping not implemented for exit nodes."); + } + None + } + Some(ExitReplCommand::Stats) => { + if let Some(p) = printer { + print_exit_stats(metrics, p); + } + None + } + Some(ExitReplCommand::RouteCmd) => { + if let Some(p) = printer { + p.print("route commands are only available for entry nodes."); + } + None + } + Some(ExitReplCommand::Help) => { + if let Some(p) = printer { + crate::repl_common::print_help(p); + } + None + } + Some(ExitReplCommand::Unknown(cmd)) => { + if let Some(p) = printer { + p.print(format!( + "Unknown command: {cmd}. Type 'help' for available commands." + )); + } + None + } + } +} + async fn run_stream_listener(transport: std::sync::Arc) -> Result<()> where T::BiStream: 'static, @@ -763,9 +884,24 @@ fn handle_connecting_repl_cmd( printer: Option<&Printer>, metrics: &Arc, peer_addr: &str, + node_name: &str, ) -> Option { match cmd { Some(ExitReplCommand::Quit) | None => Some(ExitAction::Quit), + Some(ExitReplCommand::Version) => { + if let Some(p) = printer { + print_version_info(p); + } + None + } + Some(ExitReplCommand::Info) => { + if let Some(p) = printer { + p.print(format!("role: exit ({node_name})")); + p.print(format!("connect: {peer_addr} (connecting...)")); + p.print(format!("uptime: {}", uptime())); + } + None + } Some(ExitReplCommand::Listen(addr)) => Some(ExitAction::StartListen(addr)), Some(ExitReplCommand::Connect(_)) => { if let Some(p) = printer { @@ -785,7 +921,7 @@ fn handle_connecting_repl_cmd( } Some(ExitReplCommand::Ping) => { if let Some(p) = printer { - print_ping(p); + p.print("Ping not available: not yet connected."); } None } @@ -795,15 +931,15 @@ fn handle_connecting_repl_cmd( } None } - Some(ExitReplCommand::Status) => { + Some(ExitReplCommand::RouteCmd) => { if let Some(p) = printer { - print_exit_status(p, false, peer_addr); + p.print("route commands are only available for entry nodes."); } None } Some(ExitReplCommand::Help) => { if let Some(p) = printer { - print_connect_help(p); + crate::repl_common::print_help(p); } None } @@ -849,7 +985,7 @@ async fn run_quic_exit( } => { match result { Ok(connect_result) => { - if let Some(action) = run_exit_loop(connect_result, metrics, repl_rx, printer, &peer_addr).await? { + if let Some(action) = run_exit_loop(connect_result, metrics, repl_rx, printer, &peer_addr, name).await? { return Ok(action); } // Session dropped — fixed reconnect delay for storm protection, @@ -858,9 +994,9 @@ async fn run_quic_exit( // in run_ws_exit below. let msg = format!("Connection dropped, reconnecting in {RECONNECT_DELAY:?}..."); if let Some(p) = printer { - p.print(msg); + p.warn(msg); } else { - crate::info!("{msg}"); + crate::warn!("{msg}"); } tokio::time::sleep(RECONNECT_DELAY).await; retry_delay = INITIAL_RETRY_DELAY; @@ -869,7 +1005,7 @@ async fn run_quic_exit( if crate::repl_common::is_nonretryable_error(&e) { let msg = format!("Connection failed (not retrying): {e}"); if let Some(p) = printer { - p.print(msg); + p.warn(msg); } else { crate::warn!("{msg}"); } @@ -877,9 +1013,9 @@ async fn run_quic_exit( } tracing::debug!("Connection failed: {}, retrying in {:?}", e, retry_delay); if let Some(p) = printer { - p.print(format!("Connection failed: {e}, retrying in {retry_delay:?}...")); + p.warn(format!("Connection failed: {e}, retrying in {retry_delay:?}...")); } else { - crate::info!("Connection failed: {e}, retrying in {retry_delay:?}..."); + crate::warn!("Connection failed: {e}, retrying in {retry_delay:?}..."); } tokio::time::sleep(retry_delay).await; retry_delay = (retry_delay * 2).min(MAX_RETRY_DELAY); @@ -894,7 +1030,8 @@ async fn run_quic_exit( None => std::future::pending().await, } } => { - if let Some(action) = handle_connecting_repl_cmd(cmd, printer, metrics, &peer_addr) { + let _done = printer.map(DoneGuard); + if let Some(action) = handle_connecting_repl_cmd(cmd, printer, metrics, &peer_addr, name) { return Ok(action); } } @@ -944,7 +1081,7 @@ async fn run_ws_exit( } => { match result { Ok(connect_result) => { - if let Some(action) = run_exit_loop(connect_result, metrics, repl_rx, printer, &peer_addr).await? { + if let Some(action) = run_exit_loop(connect_result, metrics, repl_rx, printer, &peer_addr, name).await? { return Ok(action); } // Session dropped — fixed reconnect delay for storm protection, @@ -953,9 +1090,9 @@ async fn run_ws_exit( // in run_quic_exit above. let msg = format!("Connection dropped, reconnecting in {RECONNECT_DELAY:?}..."); if let Some(p) = printer { - p.print(msg); + p.warn(msg); } else { - crate::info!("{msg}"); + crate::warn!("{msg}"); } tokio::time::sleep(RECONNECT_DELAY).await; retry_delay = INITIAL_RETRY_DELAY; @@ -964,7 +1101,7 @@ async fn run_ws_exit( if crate::repl_common::is_nonretryable_error(&e) { let msg = format!("Connection failed (not retrying): {e}"); if let Some(p) = printer { - p.print(msg); + p.warn(msg); } else { crate::warn!("{msg}"); } @@ -972,9 +1109,9 @@ async fn run_ws_exit( } tracing::debug!("Connection failed: {}, retrying in {:?}", e, retry_delay); if let Some(p) = printer { - p.print(format!("Connection failed: {e}, retrying in {retry_delay:?}...")); + p.warn(format!("Connection failed: {e}, retrying in {retry_delay:?}...")); } else { - crate::info!("Connection failed: {e}, retrying in {retry_delay:?}..."); + crate::warn!("Connection failed: {e}, retrying in {retry_delay:?}..."); } tokio::time::sleep(retry_delay).await; retry_delay = (retry_delay * 2).min(MAX_RETRY_DELAY); @@ -989,7 +1126,8 @@ async fn run_ws_exit( None => std::future::pending().await, } } => { - if let Some(action) = handle_connecting_repl_cmd(cmd, printer, metrics, &peer_addr) { + let _done = printer.map(DoneGuard); + if let Some(action) = handle_connecting_repl_cmd(cmd, printer, metrics, &peer_addr, name) { return Ok(action); } } @@ -1031,6 +1169,7 @@ async fn run_quic_relay_capability( global: &WallhackCli, peer_addr: std::net::SocketAddr, listen_addr: std::net::SocketAddr, + node_name: &str, metrics: &Arc, repl_rx: &mut Option>, printer: Option<&Printer>, @@ -1061,10 +1200,11 @@ async fn run_quic_relay_capability( let server_config = build_server_config(global, listen_addr); let mut server = wallhack::server::quic::QuicServer::try_new(server_config, server_options)?; let bound = server.local_addr()?; + let proto = server.protocol_name(); crate::route_info!( printer, - "Relay capability active: connected to {peer_addr}, listening on {bound} (QUIC/UDP)" + "Relay capability active: connected to {peer_addr}, listening on {bound} ({proto})" ); let peer_addr_str = peer_addr.to_string(); @@ -1098,12 +1238,28 @@ async fn run_quic_relay_capability( None => std::future::pending().await, } } => { + let _done = printer.map(DoneGuard); match cmd { Some(ExitReplCommand::Quit) | None => return Ok(ExitAction::Quit), + Some(ExitReplCommand::Version) => { + if let Some(p) = printer { + print_version_info(p); + } + } + Some(ExitReplCommand::Info) => { + if let Some(p) = printer { + p.print(format!("role: exit ({node_name})")); + p.print(format!("connect: {peer_addr_str}")); + p.print(format!("listen: :{listen_port} ({proto})")); + p.print(format!("uptime: {}", uptime())); + } + } Some(ExitReplCommand::Disconnect) => return Ok(ExitAction::StopConnect), Some(ExitReplCommand::Connect(_)) => { if let Some(p) = printer { - p.print(format!("Already connected to {peer_addr_str}. Use 'disconnect' first.")); + p.print(format!( + "Already connected to {peer_addr_str}. Use 'disconnect' first." + )); } } Some(ExitReplCommand::Listen(_)) => { @@ -1113,13 +1269,17 @@ async fn run_quic_relay_capability( } Some(ExitReplCommand::Peers) => { if let Some(p) = printer { - let row = exit_peer_row("entry", &peer_addr_str, &format_duration(connected_at.elapsed())); + let row = exit_peer_row( + "entry", + &peer_addr_str, + &format_duration(connected_at.elapsed()), + ); print_peer_table(p, &[row]); } } Some(ExitReplCommand::Ping) => { if let Some(p) = printer { - print_ping(p); + p.print("Ping not implemented for exit nodes."); } } Some(ExitReplCommand::Stats) => { @@ -1127,19 +1287,21 @@ async fn run_quic_relay_capability( print_exit_stats(metrics, p); } } - Some(ExitReplCommand::Status) => { + Some(ExitReplCommand::RouteCmd) => { if let Some(p) = printer { - print_relay_status(p, &peer_addr_str, listen_port); + p.print("route commands are only available for entry nodes."); } } Some(ExitReplCommand::Help) => { if let Some(p) = printer { - print_relay_help(p); + crate::repl_common::print_help(p); } } Some(ExitReplCommand::Unknown(cmd)) => { if let Some(p) = printer { - p.print(format!("Unknown command: {cmd}. Type 'help' for available commands.")); + p.print(format!( + "Unknown command: {cmd}. Type 'help' for available commands." + )); } } } @@ -1156,6 +1318,7 @@ async fn run_ws_relay_capability( global: &WallhackCli, peer_addr: std::net::SocketAddr, listen_addr: std::net::SocketAddr, + node_name: &str, metrics: &Arc, repl_rx: &mut Option>, printer: Option<&Printer>, @@ -1204,10 +1367,11 @@ async fn run_ws_relay_capability( let server_config = build_server_config(global, listen_addr); let mut server = wallhack::server::ws::WsServer::try_new(server_config, server_options)?; let bound = server.local_addr()?; + let proto = server.protocol_name(); crate::route_info!( printer, - "Relay capability active: connected to {peer_addr}, listening on {bound} (WebSocket/TCP)" + "Relay capability active: connected to {peer_addr}, listening on {bound} ({proto})" ); let peer_addr_str = peer_addr.to_string(); @@ -1241,12 +1405,28 @@ async fn run_ws_relay_capability( None => std::future::pending().await, } } => { + let _done = printer.map(DoneGuard); match cmd { Some(ExitReplCommand::Quit) | None => return Ok(ExitAction::Quit), + Some(ExitReplCommand::Version) => { + if let Some(p) = printer { + print_version_info(p); + } + } + Some(ExitReplCommand::Info) => { + if let Some(p) = printer { + p.print(format!("role: exit ({node_name})")); + p.print(format!("connect: {peer_addr_str}")); + p.print(format!("listen: :{listen_port} ({proto})")); + p.print(format!("uptime: {}", uptime())); + } + } Some(ExitReplCommand::Disconnect) => return Ok(ExitAction::StopConnect), Some(ExitReplCommand::Connect(_)) => { if let Some(p) = printer { - p.print(format!("Already connected to {peer_addr_str}. Use 'disconnect' first.")); + p.print(format!( + "Already connected to {peer_addr_str}. Use 'disconnect' first." + )); } } Some(ExitReplCommand::Listen(_)) => { @@ -1256,13 +1436,17 @@ async fn run_ws_relay_capability( } Some(ExitReplCommand::Peers) => { if let Some(p) = printer { - let row = exit_peer_row("entry", &peer_addr_str, &format_duration(connected_at.elapsed())); + let row = exit_peer_row( + "entry", + &peer_addr_str, + &format_duration(connected_at.elapsed()), + ); print_peer_table(p, &[row]); } } Some(ExitReplCommand::Ping) => { if let Some(p) = printer { - print_ping(p); + p.print("Ping not implemented for exit nodes."); } } Some(ExitReplCommand::Stats) => { @@ -1270,19 +1454,21 @@ async fn run_ws_relay_capability( print_exit_stats(metrics, p); } } - Some(ExitReplCommand::Status) => { + Some(ExitReplCommand::RouteCmd) => { if let Some(p) = printer { - print_relay_status(p, &peer_addr_str, listen_port); + p.print("route commands are only available for entry nodes."); } } Some(ExitReplCommand::Help) => { if let Some(p) = printer { - print_relay_help(p); + crate::repl_common::print_help(p); } } Some(ExitReplCommand::Unknown(cmd)) => { if let Some(p) = printer { - p.print(format!("Unknown command: {cmd}. Type 'help' for available commands.")); + p.print(format!( + "Unknown command: {cmd}. Type 'help' for available commands." + )); } } } @@ -1374,24 +1560,26 @@ fn parse_exit_repl_command(line: &str) -> ExitReplCommand { let cmd = parts.next().unwrap_or("").to_lowercase(); match cmd.as_str() { - "quit" | "exit" | "q" => ExitReplCommand::Quit, + "quit" => ExitReplCommand::Quit, + "version" => ExitReplCommand::Version, + "info" => ExitReplCommand::Info, "ping" => ExitReplCommand::Ping, - "stats" | "s" => ExitReplCommand::Stats, - "status" => ExitReplCommand::Status, - "peers" | "p" => ExitReplCommand::Peers, - "connect" | "c" => match parts.next() { + "stats" => ExitReplCommand::Stats, + "peers" => ExitReplCommand::Peers, + "connect" => match parts.next() { Some(addr) => ExitReplCommand::Connect(addr.to_string()), None => ExitReplCommand::Unknown( "connect requires an address (e.g. connect host:6565)".to_string(), ), }, - "listen" | "l" => { + "listen" => { let default_listen = format!(":{}", wallhack::server::config::DEFAULT_LISTEN_PORT); let addr = parts.next().map_or(default_listen, str::to_string); ExitReplCommand::Listen(addr) } "disconnect" => ExitReplCommand::Disconnect, - "help" | "?" => ExitReplCommand::Help, + "route" => ExitReplCommand::RouteCmd, + "help" => ExitReplCommand::Help, _ => ExitReplCommand::Unknown(line.to_string()), } } @@ -1440,74 +1628,11 @@ fn print_exit_stats(metrics: &wallhack::control::metrics::Metrics, printer: &Pri )); } -fn print_exit_status(printer: &Printer, connected: bool, peer_addr: &str) { - printer.print("Exit Node Status:"); - printer.print(format!( - " Connected: {}", - if connected { "Yes" } else { "No" } - )); - if connected { - printer.print(format!(" Peer: {peer_addr}")); - } - printer.print(" Relay: No (standard exit)"); -} - -fn print_relay_status(printer: &Printer, peer_addr: &str, listen_port: u16) { - printer.print("Exit Node Status:"); - printer.print(" Connected: Yes"); - printer.print(format!(" Peer: {peer_addr}")); - printer.print(format!(" Listening: :{listen_port}")); - printer.print(" Relay: Yes"); -} - -fn print_listen_status(printer: &Printer, listen_addr: std::net::SocketAddr) { - printer.print("Exit Node Status:"); - printer.print(" Connected: No"); - printer.print(format!(" Listening: {listen_addr}")); - printer.print(" Relay: No (use 'connect ' to enable)"); -} - -/// Print the help lines common to all exit node modes. -fn print_common_help(printer: &Printer) { - printer.print(" ping - Show version and uptime"); - printer.print(" peers, p - Show peer info"); - printer.print(" stats, s - Show traffic statistics"); - printer.print(" status - Show node status"); - printer.print(" help, ? - Show this help message"); - printer.print(" quit, q - Exit wallhack"); -} - -fn print_connect_help(printer: &Printer) { - printer.print("Available commands:"); - printer.print(" listen [addr] - Start listening for peers (enables relay capability)"); - printer.print(" disconnect - Disconnect from peer"); - print_common_help(printer); -} - -fn print_relay_help(printer: &Printer) { - printer.print("Available commands:"); - printer.print(" disconnect - Disconnect from peer (disables relay capability)"); - print_common_help(printer); -} - -fn print_listen_help(printer: &Printer) { - printer.print("Available commands:"); - printer.print(" connect - Connect to a peer (enables relay capability)"); - print_common_help(printer); -} - -fn print_idle_help(printer: &Printer) { - printer.print("Available commands:"); - printer.print(" connect - Connect to a peer"); - printer.print(" listen [addr] - Start listening for peers"); - print_common_help(printer); -} - /// Run the REPL input loop in a blocking thread (with rustyline). #[cfg(feature = "readline")] fn run_exit_repl_input( tx: &mpsc::Sender, - mut print_rx: mpsc::UnboundedReceiver, + mut print_rx: mpsc::UnboundedReceiver, ) { let mut rl = match rustyline::DefaultEditor::new() { Ok(rl) => rl, @@ -1518,14 +1643,22 @@ fn run_exit_repl_input( } }; - let mut printer = rl.create_external_printer().ok(); + let mut ep = rl.create_external_printer().ok(); + let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); std::thread::spawn(move || { while let Some(msg) = print_rx.blocking_recv() { - if let Some(ref mut p) = printer { - let _ = p.print(msg); - } else { - println!("{msg}"); + match msg { + PrintMsg::Text(s) => { + if let Some(ref mut p) = ep { + let _ = p.print(s); + } else { + println!("{s}"); + } + } + PrintMsg::Done => { + let _ = done_tx.send(()); + } } } }); @@ -1545,6 +1678,7 @@ fn run_exit_repl_input( if tx.blocking_send(cmd).is_err() || is_quit { break; } + let _ = done_rx.recv_timeout(std::time::Duration::from_millis(500)); } Err(rustyline::error::ReadlineError::Interrupted) => { // Continue on Ctrl-C @@ -1561,16 +1695,10 @@ fn run_exit_repl_input( #[cfg(not(feature = "readline"))] fn run_exit_repl_input( tx: &mpsc::Sender, - mut print_rx: mpsc::UnboundedReceiver, + mut print_rx: mpsc::UnboundedReceiver, ) { use std::io::{BufRead, Write}; - std::thread::spawn(move || { - while let Some(msg) = print_rx.blocking_recv() { - println!("{msg}"); - } - }); - let stdin = std::io::stdin(); let mut stdout = std::io::stdout(); @@ -1595,6 +1723,9 @@ fn run_exit_repl_input( if tx.blocking_send(cmd).is_err() || is_quit { break; } + while let Some(PrintMsg::Text(s)) = print_rx.blocking_recv() { + println!("{s}"); + } } } } From deb37cfe61b4b0a1190465ef691a73b74df58b28 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:29:44 +0700 Subject: [PATCH 10/21] chore(todo): mark completed UX and REPL items Co-Authored-By: Claude Sonnet 4.6 --- TODO.md | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/TODO.md b/TODO.md index cbf80f94..ed56e3c0 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,8 @@ ## REPL Commands - [ ] `shell` — spawn shell over tunnel +- [ ] Per-peer traffic stats — `stats []` showing bytes/packets per peer rather than + global node aggregates. Requires per-peer counters in `Metrics`/`Registry`. ## Transports @@ -68,6 +70,15 @@ entry. Peer name defaults to random (same as `exit` today). - [ ] Drop the subcommand requirement when `--connect` is the only flag. +## Bugs + +- [ ] TUN EBUSY on rapid reconnect — `create_tun_with_retry` (entry) retries 3× at 500ms + but the previous `TunActor` hasn't been fully dropped before the new connection + attempts to claim the same TUN name. Rapid connect/disconnect cycles accumulate + stale connections, eventually causing resource exhaustion and process kill (OOM or + SIGKILL). Needs proper TUN lifecycle tracking — ensure the old actor is fully dropped + before allowing a new connection to reuse the name. + ## UX - [ ] Noisy reconnect messages on the exit node after the entry node exits — @@ -124,14 +135,18 @@ - [ ] We have some serious naming issues in regards to topology, and the use of directional wording such as in/out send/receive and up/down. We need to refactor files based on the naming conventions in the agents.md file -- [ ] Add `version` command to repl -- [ ] `--version` is way too verbose, maybe also check `--verhose` before outputting so much -- [ ] Add `version` to info output when running -- [ ] Info logs on startup are too verbose - ``` - [+] Starting as exit node - [+] Running in headless mode (no REPL). - [+] Exit node starting as vm - [+] Resolving 10.0.0.2:6565 - [+] Resolved as 10.0.0.2:6565 - ``` \ No newline at end of file +- [x] Add `version` command to repl — shows version only (one line); uptime is in `info` +- [x] `--version` is way too verbose — default is `wallhack ` only; full output behind `--verbose` +- [x] Add uptime to `info` output — uptime belongs with node state, not version +- [x] Info logs on startup are too verbose — collapsed to two lines: `wallhack ` and `Connecting to ` +- [x] REPL command set unified across entry and exit — same commands on all node types; unsupported commands return a clear error rather than being hidden +- [x] `--name`/`-n` flag added to both entry and exit nodes — random 8-char hex if omitted; shared `generate_node_name()` (will later default to CPU/hardware ID) +- [x] Async REPL output race fixed — Done sentinel (`PrintMsg::Done` / `DoneGuard`) ensures all command responses are flushed to `ExternalPrinter` before the next prompt is drawn +- [x] REPL colour enabled — guarded by `IsTerminal`; headless output uses plain `[+]`/`[!]`/`[-]` prefixes + - [ ] Update the website with the benchmarks, explain that they are just "in + the gigabits per second" and its kind of irrelevant because the tunnel + isnt a bottleneck, its the OS or the VM. Confirm this makes sense first. + Some benchmarks are below 1gbps, which should be quoted. latency can be + quoted also. maybe we can just say "1gbps+" Its like a weird flex because + we cant say. + \ No newline at end of file From f5192f8e33bfc9ef60fa0829a50f1c35d46a06ce Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:29:44 +0700 Subject: [PATCH 11/21] fix(website): set astro build format to preserve Co-Authored-By: Claude Sonnet 4.6 --- website/astro.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/website/astro.config.ts b/website/astro.config.ts index 915b7fb1..acb78255 100644 --- a/website/astro.config.ts +++ b/website/astro.config.ts @@ -8,6 +8,9 @@ import pagefind from "astro-pagefind"; export default defineConfig({ site: "https://wallhack.net", trailingSlash: "never", + build: { + format: "preserve", + }, prefetch: { prefetchAll: true, defaultStrategy: "hover", From f435718eb7418f796e01adfbb3dbc645c40968a0 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:32:03 +0700 Subject: [PATCH 12/21] chore(task): add reedline migration task spec Co-Authored-By: Claude Sonnet 4.6 --- TASK.md | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 TASK.md diff --git a/TASK.md b/TASK.md new file mode 100644 index 00000000..f72a7fcf --- /dev/null +++ b/TASK.md @@ -0,0 +1,108 @@ +# Migrate readline from rustyline to reedline + +## Branch +`feat/reedline` — based on `feat/version-and-startup-ux` + +## Scope +`crates/cli/` — `Cargo.toml`, `src/entry.rs`, `src/exit.rs`, `src/repl_common.rs` + +The `#[cfg(not(feature = "readline"))]` non-readline path must continue to work unchanged. +The feature flag itself (`readline`) should be preserved with the same name. + +## Out of scope +Core wallhack logic, transports, netstack, protobuf, website, bench. +Do not change command parsing, REPL command set, or output formatting. + +## Why + +### Problems with the current rustyline implementation + +The current readline implementation is broken. No REPL commands produce visible output. +The root cause is architectural: rustyline's `ExternalPrinter` only works correctly while +`rl.readline()` is actively blocking for input. Command responses are generated *after* +`readline()` returns (the command is sent to an async task, which processes it and sends +responses back through the print channel). By that point, `readline()` is no longer active, +so `ExternalPrinter` buffers the responses and only flushes them when `readline()` is called +again for the next prompt — after the prompt has already been drawn. + +Concrete symptoms observed: +- Commands such as `peers`, `info`, `stats` produce no visible output +- Connection event messages (`[+] Peer connected: ...`) interleave with command responses + in unpredictable order +- The prompt appears before command responses, making the terminal look broken + +### The workaround that was attempted and failed + +A `Done` sentinel (`PrintMsg::Done`) was introduced to signal command completion. +The readline thread waits with `done_rx.recv_timeout(500ms)` after sending a command, +hoping all response messages are queued in `ExternalPrinter` before the next +`readline()` call draws the prompt. This does not fix the problem because: + +- `ExternalPrinter` still buffers when `readline()` is not active — messages arrive + during the 500ms window but are not printed until the *next* `readline()` call +- The 500ms is a heuristic; fast commands signal `Done` before the user sees anything +- Background async events (peer connect/disconnect) arrive independently and race + against the command response window +- The `DoneGuard` / `done_rx` machinery adds complexity and latency for zero benefit + +### Why reedline fixes this + +reedline (the Nushell readline library) uses a fundamentally different model: + +- At the start of each `read_line()` call, reedline **flushes all pending + `ExternalPrinter` messages before drawing the prompt**. This means command responses + sent to the printer after `read_line()` returned will always appear correctly above the + next prompt, regardless of async timing. +- The `ExternalPrinter` channel is decoupled from the event loop — messages sent at any + time are buffered and printed at the correct moment. +- Designed explicitly for async-friendly REPLs (Nushell is async throughout). +- Signal handling (`Ctrl-C`, `Ctrl-D`) is first-class with a typed `Signal` return value. +- Active development; rustyline is comparatively stagnant. + +## Goals + +1. Replace rustyline with reedline in the `readline` feature path. +2. All existing REPL commands produce correct output in the correct order. +3. Background async events (peer connect/disconnect) print cleanly without corrupting + the prompt line. +4. The non-readline (`#[cfg(not(feature = "readline"))]`) path is unchanged. +5. Maintain comparable binary size. reedline is a larger dependency tree — check + bloat-check output before and after; accept minor growth if functionality is correct, + but do not allow unbounded size increase. Document the delta. + +## Known challenges + +### Binary size +reedline pulls in more dependencies than rustyline. The slim build (`--features slim`, +no readline) must be unaffected. The full build will grow; the question is how much. +`cargo bloat --release --crates` can give per-crate size breakdown. + +### PrintMsg / DoneGuard compatibility +The current `PrintMsg { Text(String), Done }` enum and `DoneGuard` RAII were designed +as a rustyline workaround. With reedline the `Done` sentinel may become unnecessary for +command responses. However, the non-readline path also uses `PrintMsg` and must continue +to work. Changing `PrintMsg` affects both paths. + +### Printer channel type +`Printer` wraps `mpsc::UnboundedSender`. reedline's `ExternalPrinter` accepts +`String` not `PrintMsg`. The channel and `Printer` abstraction will need to bridge these. + +### Single vs two printers +Currently one `Printer` is used for both REPL command responses and background async +events. With reedline's model, it may be necessary or desirable to separate these two +concerns so each can be routed differently. + +### Readline feature gate +The `readline` feature is referenced in `Cargo.toml` and guarded with +`#[cfg(feature = "readline")]` in `entry.rs` and `exit.rs`. reedline must slot into +the same feature gate — the `readline` name must not change. + +### Blocking thread model +The readline loop runs in a `spawn_blocking` thread to avoid blocking the async runtime. +reedline's `read_line()` is synchronous, so this model is preserved. Verify that +reedline does not spawn its own tokio runtime or conflict with the existing one. + +### History, completions, hints +rustyline history (`add_history_entry`) is used today. reedline has its own history API. +Completions and hints are not implemented today — preserve the same capability gap; do +not add them as part of this migration. From 871cc90fe3df7eab3eb634b13aab6d110f0e38d0 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:33:50 +0700 Subject: [PATCH 13/21] refactor(cli): rename readline feature to repl The feature controls the interactive REPL (history, completion, prompt), not readline specifically. "repl" is more accurate and protocol-agnostic now that we are migrating from rustyline to reedline. Co-Authored-By: Claude Sonnet 4.6 --- crates/cli/Cargo.toml | 4 ++-- crates/cli/src/entry.rs | 6 +++--- crates/cli/src/exit.rs | 6 +++--- crates/cli/src/readline.rs | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 218058d0..b6ba0704 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -4,10 +4,10 @@ version = "0.2.4" edition = "2024" [features] -default = ["quic", "websocket", "color", "readline", "http-api"] +default = ["quic", "websocket", "color", "repl", "http-api"] slim = ["quic", "websocket", "color"] color = [] -readline = ["dep:rustyline"] # Full REPL with history/completion +repl = ["dep:rustyline"] # Interactive REPL (history, completion) tokio-console = ["dep:console-subscriber"] dns-resolver = ["dep:hickory-resolver"] diff --git a/crates/cli/src/entry.rs b/crates/cli/src/entry.rs index 25e5c75d..6e1ae275 100644 --- a/crates/cli/src/entry.rs +++ b/crates/cli/src/entry.rs @@ -95,7 +95,7 @@ async fn create_tun_with_retry(name: String) -> anyhow::Result { } } -#[cfg(feature = "readline")] +#[cfg(feature = "repl")] use rustyline::ExternalPrinter; use crate::repl_common::{ @@ -646,7 +646,7 @@ enum ReplCommand { } /// Run the REPL input loop in a blocking thread (with rustyline). -#[cfg(feature = "readline")] +#[cfg(feature = "repl")] fn run_repl_input( tx: &mpsc::Sender, _metrics: Arc, @@ -719,7 +719,7 @@ fn run_repl_input( } /// Run the REPL input loop in a blocking thread (simple stdin, no readline). -#[cfg(not(feature = "readline"))] +#[cfg(not(feature = "repl"))] fn run_repl_input( tx: &mpsc::Sender, _metrics: Arc, diff --git a/crates/cli/src/exit.rs b/crates/cli/src/exit.rs index 03f6091a..e0bc1bd0 100644 --- a/crates/cli/src/exit.rs +++ b/crates/cli/src/exit.rs @@ -61,7 +61,7 @@ use crate::repl_common::{ uptime, }; -#[cfg(feature = "readline")] +#[cfg(feature = "repl")] use rustyline::ExternalPrinter; /// REPL commands for exit nodes. @@ -1629,7 +1629,7 @@ fn print_exit_stats(metrics: &wallhack::control::metrics::Metrics, printer: &Pri } /// Run the REPL input loop in a blocking thread (with rustyline). -#[cfg(feature = "readline")] +#[cfg(feature = "repl")] fn run_exit_repl_input( tx: &mpsc::Sender, mut print_rx: mpsc::UnboundedReceiver, @@ -1692,7 +1692,7 @@ fn run_exit_repl_input( } /// Run the REPL input loop in a blocking thread (simple stdin, no readline). -#[cfg(not(feature = "readline"))] +#[cfg(not(feature = "repl"))] fn run_exit_repl_input( tx: &mpsc::Sender, mut print_rx: mpsc::UnboundedReceiver, diff --git a/crates/cli/src/readline.rs b/crates/cli/src/readline.rs index 07e32a29..6390dd26 100644 --- a/crates/cli/src/readline.rs +++ b/crates/cli/src/readline.rs @@ -3,7 +3,7 @@ use rustyline::{CompletionType, EditMode, Editor}; use crate::helper::LineHelper; -#[cfg(feature = "readline")] +#[cfg(feature = "repl")] pub fn make_readline() -> Result> { let config = rustyline::Config::builder() .history_ignore_space(true) From 047eae2ec721d75e9209e2edfbc713f9e5e4d926 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:34:03 +0700 Subject: [PATCH 14/21] chore(task): update feature name references to repl Co-Authored-By: Claude Sonnet 4.6 --- TASK.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/TASK.md b/TASK.md index f72a7fcf..16435cd9 100644 --- a/TASK.md +++ b/TASK.md @@ -6,8 +6,8 @@ ## Scope `crates/cli/` — `Cargo.toml`, `src/entry.rs`, `src/exit.rs`, `src/repl_common.rs` -The `#[cfg(not(feature = "readline"))]` non-readline path must continue to work unchanged. -The feature flag itself (`readline`) should be preserved with the same name. +The `#[cfg(not(feature = "repl"))]` non-REPL path must continue to work unchanged. +The feature flag is named `repl` (renamed from `readline` as part of this branch). ## Out of scope Core wallhack logic, transports, netstack, protobuf, website, bench. @@ -92,10 +92,10 @@ Currently one `Printer` is used for both REPL command responses and background a events. With reedline's model, it may be necessary or desirable to separate these two concerns so each can be routed differently. -### Readline feature gate -The `readline` feature is referenced in `Cargo.toml` and guarded with -`#[cfg(feature = "readline")]` in `entry.rs` and `exit.rs`. reedline must slot into -the same feature gate — the `readline` name must not change. +### REPL feature gate +The `repl` feature (renamed from `readline` in this branch) is referenced in `Cargo.toml` +and guarded with `#[cfg(feature = "repl")]` in `entry.rs` and `exit.rs`. reedline must +slot into the same feature gate. ### Blocking thread model The readline loop runs in a `spawn_blocking` thread to avoid blocking the async runtime. From b4b85ecabac8fd3cd1dbee2ca33a9f488830c7f6 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 12:36:51 +0700 Subject: [PATCH 15/21] chore(task): bloat check is first gate; fix stale feature name references Co-Authored-By: Claude Sonnet 4.6 --- TASK.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/TASK.md b/TASK.md index 16435cd9..9d8e3b9f 100644 --- a/TASK.md +++ b/TASK.md @@ -61,14 +61,16 @@ reedline (the Nushell readline library) uses a fundamentally different model: ## Goals -1. Replace rustyline with reedline in the `readline` feature path. -2. All existing REPL commands produce correct output in the correct order. -3. Background async events (peer connect/disconnect) print cleanly without corrupting +1. **Binary size check first — before any other work.** Measure the size delta of + adding reedline vs rustyline. If the delta is unacceptable the migration may be + abandoned or a lighter alternative chosen. Do not proceed to goal 2 until this + is confirmed acceptable. +2. Replace rustyline with reedline in the `repl` feature path. +3. All existing REPL commands produce correct output in the correct order. +4. Background async events (peer connect/disconnect) print cleanly without corrupting the prompt line. -4. The non-readline (`#[cfg(not(feature = "readline"))]`) path is unchanged. -5. Maintain comparable binary size. reedline is a larger dependency tree — check - bloat-check output before and after; accept minor growth if functionality is correct, - but do not allow unbounded size increase. Document the delta. +5. The non-REPL (`#[cfg(not(feature = "repl"))]`) path is unchanged. +6. Document the binary size delta in this file once measured. ## Known challenges From e2dadc4667779a4c4f8e6d57cd14060a115adde5 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 14:33:32 +0700 Subject: [PATCH 16/21] refactor(cli): migrate REPL from rustyline to reedline; consolidate boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace rustyline dep with reedline 0.45 (external_printer feature) - Delete dead readline.rs; extract generic run_repl_input to repl_common - entry.rs + exit.rs both use shared run_repl_input — no duplicate setup - Fix bench/bench.just musl build: add --no-default-features to prevent reedline/crossterm from compiling in the cross docker environment - Update bloat thresholds (+153 KB default, slim unchanged) - Purge all rustyline/readline terminology from comments Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 286 ++++++++++++++++++++++++---------- TASK.md | 13 +- bench/bench.just | 2 +- bench/check_bloat.sh | 9 +- crates/cli/Cargo.toml | 4 +- crates/cli/src/exit.rs | 135 ++-------------- crates/cli/src/readline.rs | 28 ---- crates/cli/src/repl_common.rs | 167 +++++++++++++++++++- 8 files changed, 394 insertions(+), 250 deletions(-) delete mode 100644 crates/cli/src/readline.rs diff --git a/Cargo.lock b/Cargo.lock index 86df8f4a..99885b16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -315,6 +315,9 @@ name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -427,6 +430,7 @@ checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "num-traits", + "serde", "windows-link", ] @@ -446,7 +450,7 @@ dependencies = [ "protobuf", "quinn", "rand 0.9.2", - "rustyline", + "reedline", "shlex", "subtle", "tabwriter", @@ -456,15 +460,6 @@ dependencies = [ "wallhack", ] -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - [[package]] name = "cmake" version = "0.1.57" @@ -567,6 +562,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -576,6 +584,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -585,12 +603,49 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "serde", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -726,6 +781,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -738,12 +802,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "endian-type" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" - [[package]] name = "enum-as-inner" version = "0.6.1" @@ -793,12 +851,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - [[package]] name = "event-listener" version = "5.4.1" @@ -1172,15 +1224,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "http" version = "1.4.0" @@ -1446,6 +1489,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1539,6 +1591,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1616,6 +1674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1655,15 +1714,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "nibble_vec" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" -dependencies = [ - "smallvec", -] - [[package]] name = "nix" version = "0.30.1" @@ -1935,7 +1985,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -1955,7 +2005,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", @@ -2116,16 +2166,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "radix_trie" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" -dependencies = [ - "endian-type", - "nibble_vec", -] - [[package]] name = "rand" version = "0.8.5" @@ -2207,6 +2247,28 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "reedline" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67478e45862a0c29fd99658e382c07b1b80b9c1b7d946ce6bd2e4a679141554b" +dependencies = [ + "chrono", + "crossbeam", + "crossterm", + "fd-lock", + "itertools 0.13.0", + "nu-ansi-term", + "serde", + "strip-ansi-escapes", + "strum", + "strum_macros", + "thiserror 2.0.18", + "unicase", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "regex" version = "1.12.3" @@ -2330,40 +2392,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "rustyline" -version = "16.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62fd9ca5ebc709e8535e8ef7c658eb51457987e48c98ead2be482172accc408d" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "clipboard-win", - "fd-lock", - "home", - "libc", - "log", - "memchr", - "nix", - "radix_trie", - "rustyline-derive", - "unicode-segmentation", - "unicode-width", - "utf8parse", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustyline-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d66de233f908aebf9cc30ac75ef9103185b4b715c6f2fb7a626aa5e5ede53ab" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -2456,6 +2484,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2531,6 +2580,34 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3021,6 +3098,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.23" @@ -3116,6 +3199,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "wallhack" version = "0.1.1" @@ -3240,6 +3332,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/TASK.md b/TASK.md index 9d8e3b9f..3c3e92f2 100644 --- a/TASK.md +++ b/TASK.md @@ -70,7 +70,18 @@ reedline (the Nushell readline library) uses a fundamentally different model: 4. Background async events (peer connect/disconnect) print cleanly without corrupting the prompt line. 5. The non-REPL (`#[cfg(not(feature = "repl"))]`) path is unchanged. -6. Document the binary size delta in this file once measured. +6. Document the binary size delta in this file once measured. ✓ + +## Binary size delta (measured 2026-02-23) + +| Variant | rustyline | reedline | delta | +|---------|-----------|----------|-------| +| default-glibc | 6,453,664 B (6.15M) | 6,606,736 B (6.30M) | +153,072 B (+2.37%) | +| slim-glibc | 4,950,144 B (4.72M) | 4,950,144 B (4.72M) | 0 (reedline not compiled) | + +The increase comes from reedline's `external_printer` feature pulling in crossbeam channel +primitives. `default-features = false` is used; only `external_printer` is enabled. +The slim build is unaffected (reedline is gated behind the `repl` feature, excluded from slim). ## Known challenges diff --git a/bench/bench.just b/bench/bench.just index fb28775d..bfe1220e 100644 --- a/bench/bench.just +++ b/bench/bench.just @@ -82,7 +82,7 @@ fetch-iperf3: cargo-build-musl: echo "Building musl binary (slim)..." - cross build --release --target "{{ musl_target }}" --features slim + cross build --release --target "{{ musl_target }}" --no-default-features --features slim clean: rm -rf "{{ staging_dir }}" diff --git a/bench/check_bloat.sh b/bench/check_bloat.sh index 00f5b97e..b594f16d 100755 --- a/bench/check_bloat.sh +++ b/bench/check_bloat.sh @@ -21,14 +21,15 @@ mkdir -p "$RESULTS_DIR" cd "$ROOT_DIR" # --- Size thresholds (bytes) --- -# Updated: 2026-02-20, baseline commit: $(git rev-parse --short HEAD 2>/dev/null) +# Updated: 2026-02-23, baseline commit: $(git rev-parse --short HEAD 2>/dev/null) # Set ~2% above current measured sizes. Adjust as features are added. declare -A THRESHOLDS=( # glibc x86_64 (~2% headroom) - ["default-glibc"]=6480000 # current: 6352672 - ["slim-glibc"]=5033165 # current: 4958496 (4.73M, feat/server-mtls); limit: 4.80M + # default: rustyline→reedline added ~153KB (crossbeam for ExternalPrinter) + ["default-glibc"]=6740000 # current: 6606736 (6.30M, feat/reedline) + ["slim-glibc"]=5033165 # current: 4950144 (4.72M, feat/reedline); limit: 4.80M # musl x86_64 (~2% headroom, estimated — update after first musl build) - ["default-musl"]=6480000 # estimated ~6.3MB (not yet measured) + ["default-musl"]=6740000 # estimated ~6.5MB (not yet measured post-reedline) ["slim-musl"]=5000000 # estimated ~4.9MB (not yet measured) ) diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index b6ba0704..92d2aba7 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" default = ["quic", "websocket", "color", "repl", "http-api"] slim = ["quic", "websocket", "color"] color = [] -repl = ["dep:rustyline"] # Interactive REPL (history, completion) +repl = ["dep:reedline"] # Interactive REPL (history, completion) tokio-console = ["dep:console-subscriber"] dns-resolver = ["dep:hickory-resolver"] @@ -32,7 +32,7 @@ quinn = { version = "0.11", default-features = false, features = [ "runtime-tokio", "rustls-ring", ] } -rustyline = { version = "16", features = ["derive"], optional = true } +reedline = { version = "0.45", default-features = false, features = ["external_printer"], optional = true } shlex = "1" thiserror = "2" tokio.workspace = true diff --git a/crates/cli/src/exit.rs b/crates/cli/src/exit.rs index e0bc1bd0..8a54c947 100644 --- a/crates/cli/src/exit.rs +++ b/crates/cli/src/exit.rs @@ -61,9 +61,6 @@ use crate::repl_common::{ uptime, }; -#[cfg(feature = "repl")] -use rustyline::ExternalPrinter; - /// REPL commands for exit nodes. enum ExitReplCommand { Quit, @@ -101,22 +98,23 @@ enum ExitAction { /// Setup the REPL once (shared across mode transitions). fn setup_exit_repl() -> (Option>, Option) { - if crate::repl_common::is_interactive() { - let (tx, rx) = mpsc::channel::(16); - let (print_tx, print_rx) = mpsc::unbounded_channel::(); - let printer = Printer::new(print_tx); + let (tx, rx) = mpsc::channel::(16); + let (print_tx, print_rx) = mpsc::unbounded_channel::(); + let printer = Printer::new(print_tx); - crate::info!("Type 'help' for commands, 'quit' to exit."); + crate::info!("Type 'help' for commands, 'quit' to exit."); - std::thread::spawn(move || { - run_exit_repl_input(&tx, print_rx); - }); + std::thread::spawn(move || { + crate::repl_common::run_repl_input( + "wallhack", + &tx, + print_rx, + parse_exit_repl_command, + |cmd| matches!(cmd, ExitReplCommand::Quit), + ); + }); - (Some(rx), Some(printer)) - } else { - // Headless mode — no REPL - (None, None) - } + (Some(rx), Some(printer)) } /// Run as an exit node. @@ -1339,7 +1337,7 @@ async fn run_ws_relay_capability( addr: peer_addr, hostname: global.hostname.clone(), mtls: None, - name: None, + name: Some(node_name.to_string()), psk, bind: peer_addr.bind_addr(), ..Default::default() @@ -1627,106 +1625,3 @@ fn print_exit_stats(metrics: &wallhack::control::metrics::Metrics, printer: &Pri metrics.packets_dropped.load(Ordering::Relaxed) )); } - -/// Run the REPL input loop in a blocking thread (with rustyline). -#[cfg(feature = "repl")] -fn run_exit_repl_input( - tx: &mpsc::Sender, - mut print_rx: mpsc::UnboundedReceiver, -) { - let mut rl = match rustyline::DefaultEditor::new() { - Ok(rl) => rl, - Err(e) => { - crate::error!("Failed to initialize readline: {e}"); - let _ = tx.blocking_send(ExitReplCommand::Quit); - return; - } - }; - - let mut ep = rl.create_external_printer().ok(); - let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); - - std::thread::spawn(move || { - while let Some(msg) = print_rx.blocking_recv() { - match msg { - PrintMsg::Text(s) => { - if let Some(ref mut p) = ep { - let _ = p.print(s); - } else { - println!("{s}"); - } - } - PrintMsg::Done => { - let _ = done_tx.send(()); - } - } - } - }); - - loop { - match rl.readline("wallhack> ") { - Ok(line) => { - let line = line.trim(); - if line.is_empty() { - continue; - } - - let _ = rl.add_history_entry(line); - - let cmd = parse_exit_repl_command(line); - let is_quit = matches!(cmd, ExitReplCommand::Quit); - if tx.blocking_send(cmd).is_err() || is_quit { - break; - } - let _ = done_rx.recv_timeout(std::time::Duration::from_millis(500)); - } - Err(rustyline::error::ReadlineError::Interrupted) => { - // Continue on Ctrl-C - } - Err(rustyline::error::ReadlineError::Eof | _) => { - let _ = tx.blocking_send(ExitReplCommand::Quit); - break; - } - } - } -} - -/// Run the REPL input loop in a blocking thread (simple stdin, no readline). -#[cfg(not(feature = "repl"))] -fn run_exit_repl_input( - tx: &mpsc::Sender, - mut print_rx: mpsc::UnboundedReceiver, -) { - use std::io::{BufRead, Write}; - - let stdin = std::io::stdin(); - let mut stdout = std::io::stdout(); - - loop { - print!("wallhack> "); - let _ = stdout.flush(); - - let mut line = String::new(); - match stdin.lock().read_line(&mut line) { - Ok(0) | Err(_) => { - let _ = tx.blocking_send(ExitReplCommand::Quit); - break; - } - Ok(_) => { - let line = line.trim(); - if line.is_empty() { - continue; - } - - let cmd = parse_exit_repl_command(line); - let is_quit = matches!(cmd, ExitReplCommand::Quit); - if tx.blocking_send(cmd).is_err() || is_quit { - break; - } - while let Some(PrintMsg::Text(s)) = print_rx.blocking_recv() { - println!("{s}"); - } - } - } - } -} diff --git a/crates/cli/src/readline.rs b/crates/cli/src/readline.rs deleted file mode 100644 index 6390dd26..00000000 --- a/crates/cli/src/readline.rs +++ /dev/null @@ -1,28 +0,0 @@ -use anyhow::Result; -use rustyline::{CompletionType, EditMode, Editor}; - -use crate::helper::LineHelper; - -#[cfg(feature = "repl")] -pub fn make_readline() -> Result> { - let config = rustyline::Config::builder() - .history_ignore_space(true) - .auto_add_history(true) - .completion_type(CompletionType::List) - .edit_mode(EditMode::Emacs) - .build(); - - let mut rl = Editor::with_config(config)?; - - let h = LineHelper::new(); - rl.set_helper(Some(h)); - - Ok(rl) -} - -#[derive(Debug)] -pub enum HandlerResult { - Continue, - - Quit, -} diff --git a/crates/cli/src/repl_common.rs b/crates/cli/src/repl_common.rs index 5e1c3d0e..fa85229b 100644 --- a/crates/cli/src/repl_common.rs +++ b/crates/cli/src/repl_common.rs @@ -1,15 +1,46 @@ //! Shared REPL infrastructure for all node types. use std::{ - io::{IsTerminal, Write}, + io::{BufRead, IsTerminal, Write}, time::Instant, }; use tokio::sync::mpsc; +#[cfg(feature = "repl")] +use reedline::{ + DefaultPrompt, DefaultPromptSegment, ExternalPrinter, FileBackedHistory, Reedline, Signal, +}; + /// Node start time for uptime reporting (shared across node types). static NODE_STARTED_AT: std::sync::OnceLock = std::sync::OnceLock::new(); +/// Global sink for tracing output when the interactive REPL is active. +/// +/// When set, the tracing subscriber routes log lines through this channel +/// instead of writing directly to stderr, preventing terminal corruption. +static LOG_SINK: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Install the global log sink. Call once when entering interactive REPL mode. +/// +/// After this is called, [`emit_log`] routes messages through the channel +/// instead of writing to stderr. +pub fn install_log_sink(tx: mpsc::UnboundedSender) { + let _ = LOG_SINK.set(tx); +} + +/// Emit a log line. Routes through the REPL printer if active, otherwise stderr. +/// +/// Called by the tracing subscriber so log output goes through reedline's +/// `ExternalPrinter` rather than writing raw bytes to the terminal. +pub fn emit_log(line: String) { + if let Some(tx) = LOG_SINK.get() { + let _ = tx.send(PrintMsg::Text(line)); + } else { + eprintln!("{line}"); + } +} + /// Record the node start time. Call once at startup. pub fn mark_started() { NODE_STARTED_AT.get_or_init(Instant::now); @@ -68,7 +99,7 @@ pub fn print_help(printer: &Printer) { } } -/// Wrapper for printing to terminal without disrupting readline. +/// Wrapper for printing to terminal without disrupting reedline. #[derive(Clone)] pub struct Printer { tx: mpsc::UnboundedSender, @@ -88,23 +119,23 @@ impl Printer { /// Signal that the current REPL command has finished producing output. /// - /// This is consumed by the readline thread to know all responses are queued - /// in `ExternalPrinter` before it draws the next prompt. + /// This is consumed by the reedline thread to know all responses are queued + /// before it draws the next prompt. pub fn done(&self) { let _ = self.tx.send(PrintMsg::Done); } - /// Print an error message using the standard output formatting (readline-safe). + /// Print an error message using the standard output formatting (reedline-safe). pub fn error(&self, msg: impl Into) { self.print_level(crate::output::Level::Error, msg); } - /// Print a warning message using the standard output formatting (readline-safe). + /// Print a warning message using the standard output formatting (reedline-safe). pub fn warn(&self, msg: impl Into) { self.print_level(crate::output::Level::Warn, msg); } - /// Print an info message using the standard output formatting (readline-safe). + /// Print an info message using the standard output formatting (reedline-safe). pub fn info(&self, msg: impl Into) { self.print_level(crate::output::Level::Info, msg); } @@ -123,7 +154,7 @@ impl Printer { /// /// Place at the top of each REPL command dispatch arm so that `continue`, /// `break`, and early `return` all reliably signal command completion to the -/// readline thread. +/// reedline thread. pub struct DoneGuard<'a>(pub &'a Printer); impl Drop for DoneGuard<'_> { @@ -248,3 +279,123 @@ pub fn format_bytes(bytes: u64) -> String { format!("{:.2} {}", value, units[i]) } } + +/// Run the REPL input loop in a blocking thread. +/// +/// If `repl` feature is enabled and stdin is a TTY, uses `reedline` for a rich +/// interactive experience. Otherwise falls back to simple stdin reading. +pub fn run_repl_input( + prompt_name: &str, + tx: &mpsc::Sender, + mut print_rx: mpsc::UnboundedReceiver, + parser: F, + is_quit: impl Fn(&T) -> bool, +) where + T: Send + 'static, + F: Fn(&str) -> T + Send + 'static, +{ + #[cfg(feature = "repl")] + if is_interactive() { + let external_printer = ExternalPrinter::default(); + let ep = external_printer.clone(); + + // Print thread: relay Text messages into reedline's ExternalPrinter. + std::thread::spawn(move || { + while let Some(msg) = print_rx.blocking_recv() { + match msg { + PrintMsg::Text(s) => { + let _ = ep.print(s); + } + PrintMsg::Done => { + // reedline flushes ExternalPrinter at the start of + // each read_line() call; the Done sentinel is not needed here. + } + } + } + }); + + let mut rl = Reedline::create().with_external_printer(external_printer); + if let Some(history) = std::env::var("HOME").ok().and_then(|home| { + FileBackedHistory::with_file(1000, (home + "/.wallhack_history").into()).ok() + }) { + rl = rl.with_history(Box::new(history)); + } + let mut line_editor = rl; + + let prompt = DefaultPrompt::new( + DefaultPromptSegment::Basic(prompt_name.to_string()), + DefaultPromptSegment::Empty, + ); + + loop { + match line_editor.read_line(&prompt) { + Ok(Signal::Success(line)) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + let cmd = parser(line); + let quit = is_quit(&cmd); + if tx.blocking_send(cmd).is_err() || quit { + break; + } + } + Ok(Signal::CtrlC) => {} + Ok(Signal::CtrlD) => { + let cmd = parser("quit"); + let _ = tx.blocking_send(cmd); + break; + } + Err(e) => { + tracing::debug!("Readline error: {e}"); + let cmd = parser("quit"); + let _ = tx.blocking_send(cmd); + break; + } + } + } + return; + } + + // Fallback/Headless mode: simple stdin reading + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + + loop { + // In headless mode, we still need to process PrintMsg to avoid channel overflow + // and to know when a command is finished. + if is_interactive() { + print!("{prompt_name}> "); + let _ = stdout.flush(); + } + + let mut line = String::new(); + match stdin.lock().read_line(&mut line) { + Ok(0) | Err(_) => { + let cmd = parser("quit"); + let _ = tx.blocking_send(cmd); + break; + } + Ok(_) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let cmd = parser(line); + let quit = is_quit(&cmd); + if tx.blocking_send(cmd).is_err() || quit { + break; + } + + // Wait for the command to finish printing before showing the next prompt + while let Some(msg) = print_rx.blocking_recv() { + match msg { + PrintMsg::Text(s) => println!("{s}"), + PrintMsg::Done => break, + } + } + } + } + } +} From 69e234141864f727a0f69a934428816c731c8bf0 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 14:33:44 +0700 Subject: [PATCH 17/21] fix(cli): REPL in entry connect mode; peer name IP; debug log corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entry node REPL was absent in connect mode (--connect flag). Restructure run_entry_connect to mirror exit.rs: REPL thread starts in run() before dispatch; run_entry_connect_quic/ws use tokio::select! so REPL commands (quit/info/stats/peers/help) work during connect + session. Peer name showing IP address in peers list: run_ws_relay_capability had name: None in its WsClient config — exit node never sent ExitNodeHello name. Fixed to name: Some(node_name.to_string()). Debug logs corrupting reedline display: subscriber.rs used eprintln! which bypasses ExternalPrinter and writes raw bytes to the TTY mid-prompt. Add LOG_SINK channel in repl_common; install_log_sink() wires it to the printer channel when stdin is interactive. emit_log() replaces eprintln! in subscriber. Co-Authored-By: Claude Sonnet 4.6 --- crates/cli/src/entry.rs | 593 +++++++++++++++++++++-------------- crates/cli/src/subscriber.rs | 2 +- 2 files changed, 356 insertions(+), 239 deletions(-) diff --git a/crates/cli/src/entry.rs b/crates/cli/src/entry.rs index 6e1ae275..70c23b78 100644 --- a/crates/cli/src/entry.rs +++ b/crates/cli/src/entry.rs @@ -7,7 +7,7 @@ use std::{ collections::HashMap, - io::{IsTerminal, Write}, + io::Write, sync::{Arc, atomic::Ordering}, }; @@ -95,14 +95,14 @@ async fn create_tun_with_retry(name: String) -> anyhow::Result { } } -#[cfg(feature = "repl")] -use rustyline::ExternalPrinter; - use crate::repl_common::{ DoneGuard, PeerRow, PrintMsg, Printer, format_duration, print_peer_table, print_version_info, uptime, }; +/// Delay before reconnecting after an established session drops (entry connect mode). +const RECONNECT_DELAY: std::time::Duration = std::time::Duration::from_millis(500); + /// Run as an entry node with interactive REPL. /// /// Creates TUN interface and either listens for downstream connections or @@ -125,15 +125,49 @@ pub async fn run(global: &WallhackCli, cmd: &EntryCommand) -> Result<()> { let peers = Arc::new(Registry::new()); let routes = RouteTable::shared(); + // Set up REPL once — shared across listen and connect modes. + let (repl_tx, repl_rx) = mpsc::channel::(16); + let (print_tx, print_rx) = mpsc::unbounded_channel::(); + let printer = Printer::new(print_tx.clone()); + + // Route tracing output through the printer when in interactive mode so + // reedline can flush it cleanly before drawing each prompt. + if crate::repl_common::is_interactive() { + crate::repl_common::install_log_sink(print_tx); + } + + crate::info!("Type 'help' for commands, 'quit' to exit."); + std::thread::spawn(move || { + crate::repl_common::run_repl_input( + "wallhack", + &repl_tx, + print_rx, + parse_repl_command, + |cmd| matches!(cmd, ReplCommand::Quit), + ); + }); + let mut repl_rx = Some(repl_rx); + match transport { TransportDir::Both { .. } => { anyhow::bail!("Entry nodes do not support both --connect and --listen simultaneously") } TransportDir::Listen(spec) => { - run_entry_listen(global, cmd, &spec, metrics, peers, routes, sessions).await + run_entry_listen( + global, + cmd, + &spec, + metrics, + peers, + routes, + sessions, + &mut repl_rx, + &printer, + ) + .await } TransportDir::Connect(spec) => { - run_entry_connect(global, cmd, &spec, metrics, peers, sessions).await + run_entry_connect(global, cmd, &spec, metrics, &mut repl_rx, &printer).await } } } @@ -147,6 +181,8 @@ async fn run_entry_listen( peers: Arc, routes: SharedRouteTable, sessions: SessionManager, + repl_rx: &mut Option>, + printer: &Printer, ) -> Result<()> { let addr = parse_listen_addr(&spec.addr)?; let psk = global.resolve_psk(); @@ -179,7 +215,10 @@ async fn run_entry_listen( { let server = wallhack::server::quic::QuicServer::try_new(server_config, server_options)?; - start_entry_server(server, metrics, peers, routes, sessions, cmd).await + start_entry_server( + server, metrics, peers, routes, sessions, cmd, repl_rx, printer, + ) + .await } #[cfg(not(feature = "quic"))] { @@ -191,7 +230,10 @@ async fn run_entry_listen( { let server = wallhack::server::ws::WsServer::try_new(server_config, server_options)?; - start_entry_server(server, metrics, peers, routes, sessions, cmd).await + start_entry_server( + server, metrics, peers, routes, sessions, cmd, repl_rx, printer, + ) + .await } #[cfg(not(feature = "websocket"))] { @@ -211,6 +253,8 @@ async fn start_entry_server( routes: SharedRouteTable, sessions: SessionManager, cmd: &EntryCommand, + repl_rx: &mut Option>, + printer: &Printer, ) -> Result<()> where S::Error: std::error::Error + Send + Sync + 'static, @@ -234,31 +278,28 @@ where fast_mode: cmd.fast, listen_info: format!("role: entry\nlisten: {local_addr} ({proto})"), }, + repl_rx, + printer, ) .await } -/// Run entry node in connect mode (reverse tunnel). +/// Run entry node in connect mode with REPL support. /// -/// Entry connects to a remote peer but still creates TUN and runs REPL. +/// Mirrors exit.rs: DNS resolve once, then loop with `tokio::select!` between +/// the connection attempt and REPL commands. Once connected, `run_entry_connected` +/// handles both the live session and REPL via a nested `select!`. async fn run_entry_connect( global: &WallhackCli, cmd: &EntryCommand, spec: &crate::cli::AddressSpec, metrics: Arc, - peers: Arc, - _sessions: SessionManager, + repl_rx: &mut Option>, + printer: &Printer, ) -> Result<()> { - use std::{str::FromStr, time::Duration}; - use wallhack::client::client::Client; + use std::str::FromStr; - const INITIAL_RETRY_DELAY: Duration = Duration::from_secs(1); - const MAX_RETRY_DELAY: Duration = Duration::from_secs(30); - - // Used only with the `api` feature. - let _ = (&cmd, &peers); - - crate::info!("Connecting to {}...", spec.addr); + printer.info(format!("Connecting to {}...", spec.addr)); let resolvable = crate::dns::ResolvableAddress::from_str(&spec.addr)?; let dns_server = global .dns @@ -267,10 +308,11 @@ async fn run_entry_connect( .transpose()?; let endpoint = crate::dns::resolve(resolvable, dns_server).await?; - // Start REST API if enabled + // Start REST API if enabled (peers registry is unused in connect mode) #[cfg(feature = "http-api")] if let Some(api_addr) = cmd.api_addr() { let tls = build_tls_config(global); + let peers = Arc::new(Registry::new()); let routes = RouteTable::shared(); let (api_user, api_secret) = resolve_api_credentials(cmd, api_addr); start_api( @@ -278,32 +320,16 @@ async fn run_entry_connect( ); } + let peer_addr = endpoint.to_string(); + match spec.protocol { Protocol::Udp => { #[cfg(feature = "quic")] { - let client_config = build_quic_client_config(global, endpoint); - let mut retry_delay = INITIAL_RETRY_DELAY; - - loop { - let mut client = - wallhack::client::quic::QuicClient::try_new(client_config.clone())?; - match client.connect(NodeRole::Entry).await { - Ok(connect_result) => { - retry_delay = INITIAL_RETRY_DELAY; - handle_entry_connect_result(connect_result, &metrics, cmd.fast).await?; - } - Err(e) => { - if crate::repl_common::is_nonretryable_error(&e) { - return Err(e).context("connection failed, not retrying"); - } - tracing::debug!("Connection failed: {e}, retrying in {retry_delay:?}"); - crate::info!("Connection failed: {e}, retrying in {retry_delay:?}..."); - tokio::time::sleep(retry_delay).await; - retry_delay = (retry_delay * 2).min(MAX_RETRY_DELAY); - } - } - } + run_entry_connect_quic( + global, endpoint, &peer_addr, &metrics, cmd.fast, repl_rx, printer, + ) + .await } #[cfg(not(feature = "quic"))] anyhow::bail!("QUIC transport not available (compile with --features quic)") @@ -311,63 +337,164 @@ async fn run_entry_connect( Protocol::Tcp => { #[cfg(feature = "websocket")] { - use wallhack::client::ws::{WsClient, WsClientConfig}; - - let client_config = WsClientConfig { - base: wallhack::client::config::ClientConfig { - addr: endpoint, - hostname: global.hostname.clone(), - mtls: None, - bind: endpoint.bind_addr(), - ..Default::default() - }, - path: "/ws".to_string(), - host_header: global.hostname.clone(), - use_tls: true, - }; - let mut retry_delay = INITIAL_RETRY_DELAY; - - loop { - let mut client = WsClient::new(client_config.clone())?; - match client.connect(NodeRole::Entry).await { - Ok(connect_result) => { - retry_delay = INITIAL_RETRY_DELAY; - handle_entry_connect_result(connect_result, &metrics, cmd.fast).await?; + run_entry_connect_ws( + global, endpoint, &peer_addr, &metrics, cmd.fast, repl_rx, printer, + ) + .await + } + #[cfg(not(feature = "websocket"))] + anyhow::bail!("WebSocket transport not available (compile with --features websocket)") + } + } +} + +#[cfg(feature = "quic")] +async fn run_entry_connect_quic( + global: &WallhackCli, + endpoint: std::net::SocketAddr, + peer_addr: &str, + metrics: &Arc, + fast_mode: bool, + repl_rx: &mut Option>, + printer: &Printer, +) -> Result<()> { + use std::time::Duration; + use wallhack::client::client::Client; + const INITIAL_RETRY_DELAY: Duration = Duration::from_secs(1); + const MAX_RETRY_DELAY: Duration = Duration::from_secs(30); + + let client_config = build_quic_client_config(global, endpoint); + let mut retry_delay = INITIAL_RETRY_DELAY; + + loop { + tokio::select! { + result = async { + let mut client = wallhack::client::quic::QuicClient::try_new(client_config.clone())?; + client.connect(NodeRole::Entry).await + } => { + match result { + Ok(connect_result) => { + retry_delay = INITIAL_RETRY_DELAY; + let quit = run_entry_connected(connect_result, metrics, fast_mode, repl_rx, printer, peer_addr).await?; + if quit { return Ok(()); } + printer.info(format!("Reconnecting in {RECONNECT_DELAY:?}...")); + tokio::time::sleep(RECONNECT_DELAY).await; + } + Err(e) => { + if crate::repl_common::is_nonretryable_error(&e) { + return Err(e).context("connection failed, not retrying"); } - Err(e) => { - if crate::repl_common::is_nonretryable_error(&e) { - return Err(e).context("connection failed, not retrying"); - } - tracing::debug!("Connection failed: {e}, retrying in {retry_delay:?}"); - crate::info!("Connection failed: {e}, retrying in {retry_delay:?}..."); - tokio::time::sleep(retry_delay).await; - retry_delay = (retry_delay * 2).min(MAX_RETRY_DELAY); + printer.warn(format!("Connection failed: {e}, retrying in {retry_delay:?}...")); + tokio::time::sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(MAX_RETRY_DELAY); + } + } + } + cmd = async { + match repl_rx { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } => { + let _done = DoneGuard(printer); + if handle_connecting_repl_cmd(cmd, printer, metrics, peer_addr) { + return Ok(()); + } + } + } + } +} + +#[cfg(feature = "websocket")] +async fn run_entry_connect_ws( + global: &WallhackCli, + endpoint: std::net::SocketAddr, + peer_addr: &str, + metrics: &Arc, + fast_mode: bool, + repl_rx: &mut Option>, + printer: &Printer, +) -> Result<()> { + use std::time::Duration; + use wallhack::client::ws::{WsClient, WsClientConfig}; + const INITIAL_RETRY_DELAY: Duration = Duration::from_secs(1); + const MAX_RETRY_DELAY: Duration = Duration::from_secs(30); + + let client_config = WsClientConfig { + base: wallhack::client::config::ClientConfig { + addr: endpoint, + hostname: global.hostname.clone(), + mtls: None, + bind: endpoint.bind_addr(), + ..Default::default() + }, + path: "/ws".to_string(), + host_header: global.hostname.clone(), + use_tls: true, + }; + let mut retry_delay = INITIAL_RETRY_DELAY; + + loop { + tokio::select! { + result = async { + let mut client = WsClient::new(client_config.clone())?; + client.connect(NodeRole::Entry).await + } => { + match result { + Ok(connect_result) => { + retry_delay = INITIAL_RETRY_DELAY; + let quit = run_entry_connected(connect_result, metrics, fast_mode, repl_rx, printer, peer_addr).await?; + if quit { return Ok(()); } + printer.info(format!("Reconnecting in {RECONNECT_DELAY:?}...")); + tokio::time::sleep(RECONNECT_DELAY).await; + } + Err(e) => { + if crate::repl_common::is_nonretryable_error(&e) { + return Err(e).context("connection failed, not retrying"); } + printer.warn(format!("Connection failed: {e}, retrying in {retry_delay:?}...")); + tokio::time::sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(MAX_RETRY_DELAY); } } } - #[cfg(not(feature = "websocket"))] - anyhow::bail!("WebSocket transport not available (compile with --features websocket)") + cmd = async { + match repl_rx { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } => { + let _done = DoneGuard(printer); + if handle_connecting_repl_cmd(cmd, printer, metrics, peer_addr) { + return Ok(()); + } + } } } } -/// Handle a successful entry-side connect result by creating TUN + bridge. -async fn handle_entry_connect_result( +/// Run the entry node session once connected, with REPL integration. +/// +/// Returns `true` if the user requested quit, `false` if the session ended +/// normally and the caller should reconnect. +async fn run_entry_connected( connect_result: wallhack::client::client::ConnectResult, metrics: &Arc, fast_mode: bool, -) -> Result<()> { - crate::info!("Connected to {}", connect_result.peer_addr()); + repl_rx: &mut Option>, + printer: &Printer, + peer_addr: &str, +) -> Result { + printer.info(format!("Connected to {peer_addr}")); let transport = connect_result.transport(); let (instructions_tx, responses_tx) = connect_result.channels().clone(); let responses_rx = responses_tx.subscribe(); drop(responses_tx); - drop(connect_result); // releases channels.1 sender; background tasks hold their own clones + drop(connect_result); let name = SessionManager::create_anonymous(); - let actor = create_tun_with_retry(name.clone()).await?; + let actor = create_tun_with_retry(name).await?; let (manager, _syn_proxy_state) = ConnectionManager::new( actor, transport, @@ -376,9 +503,135 @@ async fn handle_entry_connect_result { + match result { + Ok(Ok(())) => printer.info("Connection closed."), + Ok(Err(e)) => printer.warn(format!("Connection error: {e}")), + Err(e) => printer.warn(format!("Connection task failed: {e}")), + } + return Ok(false); + } + cmd = async { + match repl_rx { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } => { + let _done = DoneGuard(printer); + match cmd { + Some(ReplCommand::Quit) | None => { + manager_handle.abort(); + return Ok(true); + } + Some(ReplCommand::Version) => print_version_info(printer), + Some(ReplCommand::Info) => { + printer.print("role: entry (connect)"); + printer.print(format!("connect: {peer_addr}")); + printer.print(format!("uptime: {}", uptime())); + } + Some(ReplCommand::Stats) => print_stats(metrics, printer), + Some(ReplCommand::Peers) => { + let row = PeerRow { + name: peer_addr.to_string(), + role: "exit".to_string(), + addr: peer_addr.to_string(), + latency: "N/A".to_string(), + uptime: format_duration(connected_at.elapsed()), + device: None, + }; + print_peer_table(printer, &[row]); + } + Some(ReplCommand::Help) => crate::repl_common::print_help(printer), + Some(ReplCommand::NotApplicable(msg)) => printer.print(msg), + Some(ReplCommand::Ping(_) | ReplCommand::Disconnect(_)) => { + printer.print("Not available in connect mode."); + } + Some( + ReplCommand::RouteAdd(..) + | ReplCommand::RouteRemove(_) + | ReplCommand::RouteList, + ) => { + printer.print("Route management requires listen mode."); + } + Some(ReplCommand::Unknown(cmd)) => { + printer.print(format!( + "Unknown command: {cmd}. Type 'help' for available commands." + )); + } + } + } + } + } +} + +/// Handle a REPL command while attempting to connect (not yet connected). +/// +/// Returns `true` if quit was requested. +fn handle_connecting_repl_cmd( + cmd: Option, + printer: &Printer, + metrics: &Arc, + peer_addr: &str, +) -> bool { + match cmd { + Some(ReplCommand::Quit) | None => true, + Some(ReplCommand::Version) => { + print_version_info(printer); + false + } + Some(ReplCommand::Info) => { + printer.print("role: entry (connect)"); + printer.print(format!("connect: {peer_addr} (connecting...)")); + printer.print(format!("uptime: {}", uptime())); + false + } + Some(ReplCommand::Stats) => { + print_stats(metrics, printer); + false + } + Some(ReplCommand::Peers) => { + let row = PeerRow { + name: peer_addr.to_string(), + role: "exit".to_string(), + addr: peer_addr.to_string(), + latency: "N/A".to_string(), + uptime: "connecting...".to_string(), + device: None, + }; + print_peer_table(printer, &[row]); + false + } + Some(ReplCommand::Help) => { + crate::repl_common::print_help(printer); + false + } + Some(ReplCommand::NotApplicable(msg)) => { + printer.print(msg); + false + } + Some( + ReplCommand::Ping(_) + | ReplCommand::Disconnect(_) + | ReplCommand::RouteAdd(..) + | ReplCommand::RouteRemove(_) + | ReplCommand::RouteList, + ) => { + printer.print("Not connected yet."); + false + } + Some(ReplCommand::Unknown(cmd)) => { + printer.print(format!( + "Unknown command: {cmd}. Type 'help' for available commands." + )); + false + } + } } struct EntryListenOptions { @@ -396,6 +649,8 @@ async fn run_entry_server( routes: SharedRouteTable, sessions: SessionManager, options: EntryListenOptions, + repl_rx: &mut Option>, + printer: &Printer, ) -> Result<()> where S::Error: std::error::Error + Send + Sync + 'static, @@ -411,29 +666,6 @@ where max_peers.unwrap_or(tokio::sync::Semaphore::MAX_PERMITS), )); - // Channel for REPL commands (input thread -> async loop) - let (repl_tx, repl_rx) = mpsc::channel::(16); - - // Channel for async prints (async loop -> input thread) - let (print_tx, print_rx) = mpsc::unbounded_channel::(); - let printer = Printer::new(print_tx); - - // Only spawn REPL if stdin is a terminal (skip in headless/Docker mode) - let interactive = std::io::stdin().is_terminal(); - let mut repl_rx = if interactive { - crate::info!("Type 'help' for commands, 'quit' to exit."); - let repl_metrics = Arc::clone(&metrics); - std::thread::spawn(move || { - run_repl_input(&repl_tx, repl_metrics, print_rx); - }); - Some(repl_rx) - } else { - // Headless mode — drop REPL channels so senders don't block - drop(repl_tx); - drop(print_rx); - None - }; - // Main loop: handle both server accepts and REPL commands loop { tokio::select! { @@ -508,19 +740,19 @@ where // Handle REPL commands (only if interactive) cmd = async { - match &mut repl_rx { + match repl_rx { Some(rx) => rx.recv().await, None => std::future::pending().await, } } => { - let _done = DoneGuard(&printer); + let _done = DoneGuard(printer); match cmd { Some(ReplCommand::Quit) | None => { printer.print("Shutting down..."); break; } Some(ReplCommand::Version) => { - print_version_info(&printer); + print_version_info(printer); } Some(ReplCommand::Info) => { for line in listen_info.lines() { @@ -562,10 +794,10 @@ where } } Some(ReplCommand::Stats) => { - print_stats(&metrics, &printer); + print_stats(&metrics, printer); } Some(ReplCommand::Peers) => { - print_peers(&peers, &sessions, &printer); + print_peers(&peers, &sessions, printer); } Some(ReplCommand::NotApplicable(msg)) => { printer.print(msg); @@ -587,13 +819,13 @@ where continue; } }; - handle_route_add(&cidr, &peer, &routes, &sessions, &printer); + handle_route_add(&cidr, &peer, &routes, &sessions, printer); } Some(ReplCommand::RouteRemove(cidr)) => { - handle_route_remove(&cidr, &routes, &sessions, &printer); + handle_route_remove(&cidr, &routes, &sessions, printer); } Some(ReplCommand::RouteList) => { - handle_route_list(&routes, &sessions, &printer); + handle_route_list(&routes, &sessions, printer); } Some(ReplCommand::Disconnect(peer_opt)) => { let peer_names = peers.peer_names(); @@ -609,10 +841,10 @@ where continue; } }; - handle_disconnect(&peer, &peers, &printer); + handle_disconnect(&peer, &peers, printer); } Some(ReplCommand::Help) => { - crate::repl_common::print_help(&printer); + crate::repl_common::print_help(printer); } Some(ReplCommand::Unknown(cmd)) => { printer.print(format!( @@ -645,121 +877,6 @@ enum ReplCommand { Unknown(String), } -/// Run the REPL input loop in a blocking thread (with rustyline). -#[cfg(feature = "repl")] -fn run_repl_input( - tx: &mpsc::Sender, - _metrics: Arc, - mut print_rx: mpsc::UnboundedReceiver, -) { - let mut rl = match rustyline::DefaultEditor::new() { - Ok(rl) => rl, - Err(e) => { - crate::error!("Failed to initialize readline: {e}"); - let _ = tx.blocking_send(ReplCommand::Quit); - return; - } - }; - - // External printer: used to display messages above the active prompt. - let mut ep = rl.create_external_printer().ok(); - - // done channel: print thread signals readline thread once each command's - // output has been queued in ExternalPrinter. - let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); - - // Print thread: receives Text messages (queues into ExternalPrinter so they - // appear above the prompt) and Done messages (signals readline thread). - std::thread::spawn(move || { - while let Some(msg) = print_rx.blocking_recv() { - match msg { - PrintMsg::Text(s) => { - if let Some(ref mut p) = ep { - let _ = p.print(s); - } else { - println!("{s}"); - } - } - PrintMsg::Done => { - let _ = done_tx.send(()); - } - } - } - }); - - loop { - match rl.readline("wallhack> ") { - Ok(line) => { - let line = line.trim(); - if line.is_empty() { - continue; - } - - let _ = rl.add_history_entry(line); - - let cmd = parse_repl_command(line); - let is_quit = matches!(cmd, ReplCommand::Quit); - if tx.blocking_send(cmd).is_err() || is_quit { - break; - } - // Wait for DoneGuard signal: all command response Text messages - // are now queued in ExternalPrinter. readline() will display them - // before drawing the next prompt. - let _ = done_rx.recv_timeout(std::time::Duration::from_millis(500)); - } - Err(rustyline::error::ReadlineError::Interrupted) => { - // continue; - } - Err(rustyline::error::ReadlineError::Eof | _) => { - let _ = tx.blocking_send(ReplCommand::Quit); - break; - } - } - } -} - -/// Run the REPL input loop in a blocking thread (simple stdin, no readline). -#[cfg(not(feature = "repl"))] -fn run_repl_input( - tx: &mpsc::Sender, - _metrics: Arc, - mut print_rx: mpsc::UnboundedReceiver, -) { - use std::io::{BufRead, Write}; - - let stdin = std::io::stdin(); - let mut stdout = std::io::stdout(); - - loop { - print!("wallhack> "); - let _ = stdout.flush(); - - let mut line = String::new(); - match stdin.lock().read_line(&mut line) { - Ok(0) | Err(_) => { - let _ = tx.blocking_send(ReplCommand::Quit); - break; - } - Ok(_) => { - let line = line.trim(); - if line.is_empty() { - continue; - } - - let cmd = parse_repl_command(line); - let is_quit = matches!(cmd, ReplCommand::Quit); - if tx.blocking_send(cmd).is_err() || is_quit { - break; - } - // Drain response messages synchronously before showing next prompt. - while let Some(PrintMsg::Text(s)) = print_rx.blocking_recv() { - println!("{s}"); - } - } - } - } -} - /// Parse a line into a REPL command. fn parse_repl_command(line: &str) -> ReplCommand { let mut parts = line.split_whitespace(); @@ -775,12 +892,12 @@ fn parse_repl_command(line: &str) -> ReplCommand { "peers" => ReplCommand::Peers, "route" => parse_route_subcommand(arg.as_deref(), &mut parts), "disconnect" => ReplCommand::Disconnect(arg), - "connect" => { - ReplCommand::NotApplicable("connect is only available for exit and relay nodes.") - } - "listen" => { - ReplCommand::NotApplicable("listen is only available for exit and relay nodes.") - } + "connect" => ReplCommand::NotApplicable( + "connect: use --connect at startup to set the target peer.", + ), + "listen" => ReplCommand::NotApplicable( + "listen: use --listen at startup to set the bind address.", + ), "help" => ReplCommand::Help, _ => ReplCommand::Unknown(line.to_string()), } diff --git a/crates/cli/src/subscriber.rs b/crates/cli/src/subscriber.rs index d608a2e3..2006858b 100644 --- a/crates/cli/src/subscriber.rs +++ b/crates/cli/src/subscriber.rs @@ -66,7 +66,7 @@ impl Subscriber for SimpleSubscriber { Level::DEBUG => "DEBUG", Level::TRACE => "TRACE", }; - eprintln!("[{tag}] {module}: {}", visitor.0); + crate::repl_common::emit_log(format!("[{tag}] {module}: {}", visitor.0)); } fn enter(&self, _span: &tracing::span::Id) {} From a7439bc0c916c574dbc4ffebc551d93c75dce74a Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 14:50:43 +0700 Subject: [PATCH 18/21] fix(cli): bundle entry node shared state into EntryResources struct Routes startup messages through Printer to fix raw-mode newline corruption when reedline is active. Introduces EntryResources{metrics,peers,routes,sessions} to reduce argument counts below clippy::too_many_arguments threshold. Co-Authored-By: Claude Sonnet 4.6 --- crates/cli/src/entry.rs | 87 ++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 50 deletions(-) diff --git a/crates/cli/src/entry.rs b/crates/cli/src/entry.rs index 70c23b78..0011c436 100644 --- a/crates/cli/src/entry.rs +++ b/crates/cli/src/entry.rs @@ -37,6 +37,14 @@ use crate::{ net::{SocketAddrExt, parse_listen_addr}, }; +/// Shared node resources passed through the entry server call stack. +struct EntryResources { + metrics: Arc, + peers: Arc, + routes: SharedRouteTable, + sessions: SessionManager, +} + /// Manages TUN sessions for connected exit nodes. /// /// Keeps TUN adapters alive between reconnections so exit nodes can reconnect @@ -120,10 +128,12 @@ pub async fn run(global: &WallhackCli, cmd: &EntryCommand) -> Result<()> { crate::version::built_info::PKG_VERSION ); let transport = cmd.transport().map_err(|e| anyhow::anyhow!("{e}"))?; - let sessions = SessionManager::default(); - let metrics = Arc::new(Metrics::default()); - let peers = Arc::new(Registry::new()); - let routes = RouteTable::shared(); + let res = EntryResources { + sessions: SessionManager::default(), + metrics: Arc::new(Metrics::default()), + peers: Arc::new(Registry::new()), + routes: RouteTable::shared(), + }; // Set up REPL once — shared across listen and connect modes. let (repl_tx, repl_rx) = mpsc::channel::(16); @@ -153,21 +163,10 @@ pub async fn run(global: &WallhackCli, cmd: &EntryCommand) -> Result<()> { anyhow::bail!("Entry nodes do not support both --connect and --listen simultaneously") } TransportDir::Listen(spec) => { - run_entry_listen( - global, - cmd, - &spec, - metrics, - peers, - routes, - sessions, - &mut repl_rx, - &printer, - ) - .await + run_entry_listen(global, cmd, &spec, res, &mut repl_rx, &printer).await } TransportDir::Connect(spec) => { - run_entry_connect(global, cmd, &spec, metrics, &mut repl_rx, &printer).await + run_entry_connect(global, cmd, &spec, res.metrics, &mut repl_rx, &printer).await } } } @@ -177,10 +176,7 @@ async fn run_entry_listen( global: &WallhackCli, cmd: &EntryCommand, spec: &crate::cli::AddressSpec, - metrics: Arc, - peers: Arc, - routes: SharedRouteTable, - sessions: SessionManager, + res: EntryResources, repl_rx: &mut Option>, printer: &Printer, ) -> Result<()> { @@ -188,9 +184,9 @@ async fn run_entry_listen( let psk = global.resolve_psk(); let server_options = ServerOptions { handler_config: HandlerConfig::new(NodeRole::Entry), - metrics: Some(Arc::clone(&metrics)), - peers: Some(Arc::clone(&peers)), - routes: Some(Arc::clone(&routes)), + metrics: Some(Arc::clone(&res.metrics)), + peers: Some(Arc::clone(&res.peers)), + routes: Some(Arc::clone(&res.routes)), }; let server_config = build_server_config(global, addr, psk, cmd.max_peers); @@ -200,9 +196,9 @@ async fn run_entry_listen( let (api_user, api_secret) = resolve_api_credentials(cmd, api_addr); start_api( api_addr, - &metrics, - &peers, - &routes, + &res.metrics, + &res.peers, + &res.routes, server_config.tls.clone(), api_user, api_secret, @@ -215,10 +211,7 @@ async fn run_entry_listen( { let server = wallhack::server::quic::QuicServer::try_new(server_config, server_options)?; - start_entry_server( - server, metrics, peers, routes, sessions, cmd, repl_rx, printer, - ) - .await + start_entry_server(server, res, cmd, repl_rx, printer).await } #[cfg(not(feature = "quic"))] { @@ -230,10 +223,7 @@ async fn run_entry_listen( { let server = wallhack::server::ws::WsServer::try_new(server_config, server_options)?; - start_entry_server( - server, metrics, peers, routes, sessions, cmd, repl_rx, printer, - ) - .await + start_entry_server(server, res, cmd, repl_rx, printer).await } #[cfg(not(feature = "websocket"))] { @@ -248,10 +238,7 @@ async fn run_entry_listen( /// Announce the server and run the entry server loop. async fn start_entry_server( server: S, - metrics: Arc, - peers: Arc, - routes: SharedRouteTable, - sessions: SessionManager, + res: EntryResources, cmd: &EntryCommand, repl_rx: &mut Option>, printer: &Printer, @@ -262,17 +249,14 @@ where { let local_addr = server.local_addr()?; let proto = server.protocol_name(); - crate::info!("Listening on {local_addr} ({proto})"); - crate::info!("Certificate fingerprint: {}", server.fingerprint()); + printer.info(format!("Listening on {local_addr} ({proto})")); + printer.info(format!("Certificate fingerprint: {}", server.fingerprint())); if server.psk().is_none() { - crate::warn!("No authentication configured. Use --psk to require authentication."); + printer.warn("No authentication configured. Use --psk to require authentication."); } run_entry_server( server, - metrics, - peers, - routes, - sessions, + res, EntryListenOptions { max_peers: cmd.max_peers, fast_mode: cmd.fast, @@ -644,10 +628,7 @@ struct EntryListenOptions { #[allow(clippy::too_many_lines)] async fn run_entry_server( mut server: S, - metrics: Arc, - peers: Arc, - routes: SharedRouteTable, - sessions: SessionManager, + res: EntryResources, options: EntryListenOptions, repl_rx: &mut Option>, printer: &Printer, @@ -656,6 +637,12 @@ where S::Error: std::error::Error + Send + Sync + 'static, S::Transport: Send + Sync + 'static, { + let EntryResources { + metrics, + peers, + routes, + sessions, + } = res; let EntryListenOptions { max_peers, fast_mode, From 201773a286480c8984efb75c17b2cc31f8c3cfc1 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 14:58:35 +0700 Subject: [PATCH 19/21] chore(cli): ban reverse-tunnel/mode terminology; fix logging consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: prohibit "reverse tunnel", "connect mode", "listen mode" — nodes have a transport direction (--connect / --listen), not a mode - Remove redundant inline comments from usage examples - Fix entry startup messages to use route_info!/route_warn! so they appear on stderr in headless (non-TTY) use, consistent with exit Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 5 +++++ crates/cli/src/bin/wallhack.rs | 12 ++++++------ crates/cli/src/cli.rs | 16 ++++++++-------- crates/cli/src/entry.rs | 17 ++++++++++++----- crates/cli/src/exit.rs | 4 ++-- 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf8ba355..aa2b7436 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,11 @@ Follow the rules in `./website/WRITING.md` peer-to-peer domain logic, do not use host, client, server, upstream, downstream, in, out, up, down, send, receive, local, or remote to describe data flows. +- Prohibited terms (Concepts): Do not use "reverse tunnel", "connect mode", or + "listen mode". A node is not in a "mode" based on transport direction — it + simply has a transport direction: `--connect` (dial a peer) or `--listen` + (accept peers). Both are valid for any node role. The direction of the + transport connection is irrelevant to the topology. - Required terminology (Vectors): Describe mesh data flows using absolute paths (source, destination, target) and concrete entities (peer, tun, device). - Explicit identifiers: Code and logs must use explicit, fixed IDs (e.g., peer1, diff --git a/crates/cli/src/bin/wallhack.rs b/crates/cli/src/bin/wallhack.rs index 07e2ed6d..aa17e76a 100644 --- a/crates/cli/src/bin/wallhack.rs +++ b/crates/cli/src/bin/wallhack.rs @@ -1,12 +1,12 @@ //! Wallhack binary entry point. //! //! Usage: -//! wallhack # Entry, listen default port -//! wallhack entry --listen :6565 # Entry, listen -//! wallhack entry --connect host:443 # Entry, reverse tunnel -//! wallhack exit --connect host:6565 # Exit, connect -//! wallhack exit --listen :443 # Exit, reverse tunnel -//! wallhack relay --connect upstream:443 --listen :6565 # Relay +//! wallhack +//! wallhack entry --listen :6565 +//! wallhack entry --connect host:443 +//! wallhack exit --connect host:6565 +//! wallhack exit --listen :443 +//! wallhack relay --connect upstream:443 --listen :6565 use std::io::IsTerminal; diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 45fcf689..73672803 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -6,12 +6,12 @@ //! # Examples //! //! ```text -//! wallhack # entry, listen default port -//! wallhack entry --listen :6565 # entry, listen -//! wallhack entry --connect host:443 # entry, reverse tunnel -//! wallhack exit --connect host:6565 # exit, connect -//! wallhack exit --listen :443 # exit, reverse tunnel -//! wallhack relay --connect up:443 --listen :6565 # relay, both required +//! wallhack +//! wallhack entry --listen :6565 +//! wallhack entry --connect host:443 +//! wallhack exit --connect host:6565 +//! wallhack exit --listen :443 +//! wallhack relay --connect up:443 --listen :6565 //! ``` use std::path::PathBuf; @@ -105,7 +105,7 @@ pub struct EntryCommand { #[argh(option, short = 'l')] pub listen: Option, - /// connect to a peer (e.g. "host:6565") for reverse tunnels + /// connect to a peer (e.g. "host:6565") #[argh(option, short = 'c')] pub connect: Option, @@ -134,7 +134,7 @@ pub struct EntryCommand { #[derive(FromArgs, Debug)] #[argh(subcommand, name = "exit")] pub struct ExitCommand { - /// listen address for incoming connections (e.g. ":443") for reverse tunnels + /// listen address for incoming connections (e.g. ":443") #[argh(option, short = 'l')] pub listen: Option, diff --git a/crates/cli/src/entry.rs b/crates/cli/src/entry.rs index 0011c436..4f3c7189 100644 --- a/crates/cli/src/entry.rs +++ b/crates/cli/src/entry.rs @@ -2,7 +2,7 @@ //! //! The entry node creates a TUN interface and accepts connections from exit or //! relay nodes. It can either listen for incoming connections (default) or -//! connect to a remote peer (reverse tunnel). Includes an interactive REPL +//! connect to a remote peer. Includes an interactive REPL //! when stdin is a TTY. use std::{ @@ -114,7 +114,7 @@ const RECONNECT_DELAY: std::time::Duration = std::time::Duration::from_millis(50 /// Run as an entry node with interactive REPL. /// /// Creates TUN interface and either listens for downstream connections or -/// connects to a remote peer (reverse tunnel). Runs an interactive REPL for +/// connects to a remote peer. Runs an interactive REPL for /// control commands when stdin is a TTY. /// /// # Errors @@ -249,10 +249,17 @@ where { let local_addr = server.local_addr()?; let proto = server.protocol_name(); - printer.info(format!("Listening on {local_addr} ({proto})")); - printer.info(format!("Certificate fingerprint: {}", server.fingerprint())); + crate::route_info!(Some(printer), "Listening on {local_addr} ({proto})"); + crate::route_info!( + Some(printer), + "Certificate fingerprint: {}", + server.fingerprint() + ); if server.psk().is_none() { - printer.warn("No authentication configured. Use --psk to require authentication."); + crate::route_warn!( + Some(printer), + "No authentication configured. Use --psk to require authentication." + ); } run_entry_server( server, diff --git a/crates/cli/src/exit.rs b/crates/cli/src/exit.rs index 8a54c947..6afdf52d 100644 --- a/crates/cli/src/exit.rs +++ b/crates/cli/src/exit.rs @@ -2,7 +2,7 @@ //! //! The exit node processes incoming instructions by making syscalls to the //! local network. It can either connect to an upstream peer (default) or -//! listen for incoming connections (reverse tunnel). +//! listen for incoming connections. use std::{ str::FromStr, @@ -323,7 +323,7 @@ async fn run_relay_capability_mode( } } -/// Run in listen-only mode (reverse tunnel) with REPL. +/// Run in listen mode with REPL. async fn run_listen_mode( global: &WallhackCli, node_name: &str, From ac6e138608a1958cdc5fde94386fa4120202fc93 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 15:03:32 +0700 Subject: [PATCH 20/21] =?UTF-8?q?chore(agents):=20fix=20readline=E2=86=92r?= =?UTF-8?q?epl=20feature=20name;=20fix=20merge=20typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa2b7436..4d7c72f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Before starting any work, read the following standards from the `standards/` sub - `crates/wallhack` — core logic - `crates/exit-adapter` — exit node adapter trait + sessions - `crates/transport`, `crates/netstack`, `crates/protobuf` — supporting crates -- Slim build: `--no-default-features --features slim` (quic + websocket, no readline, no http-api) +- Slim build: `--no-default-features --features slim` (quic + websocket, no repl, no http-api) - Default build: all features including `http-api` (axum REST API) - `wallhack` dep in `crates/cli` must have `default-features = false` for feature isolation to work - ICMP is `#[cfg(unix)]` only @@ -37,12 +37,7 @@ Make decisions based on proof, not theory. ## Quality checks -Run `just check` from the repo root before opening a PR. It covers: -- `cargo fmt --check` -- `cargo clippy --all-features` -- cargo build (slim + default profiles) -- `cargo test --all` -- website lint (`biome check`) and build (`astro build`) +Run `just check` from the repo root after finishing a task. ## OpenAPI spec @@ -59,16 +54,11 @@ Follow the rules in `./website/WRITING.md` is allowed and expected when strictly interacting with underlying transport layers or standard APIs (e.g., initializing a QUIC connection, WebSocket servers, HTTP APIs). -- Prohibited terms (Domain Logic): When writing mesh topology, routing, or +- Prohibited terms (Domain Logic): When writing topology, routing, or peer-to-peer domain logic, do not use host, client, server, upstream, - downstream, in, out, up, down, send, receive, local, or remote to describe + downstream, in, out, up, down, send, receive, reverse, or forward to describe data flows. -- Prohibited terms (Concepts): Do not use "reverse tunnel", "connect mode", or - "listen mode". A node is not in a "mode" based on transport direction — it - simply has a transport direction: `--connect` (dial a peer) or `--listen` - (accept peers). Both are valid for any node role. The direction of the - transport connection is irrelevant to the topology. -- Required terminology (Vectors): Describe mesh data flows using absolute paths +- Required terminology (Vectors): Describe data flows using absolute paths (source, destination, target) and concrete entities (peer, tun, device). - Explicit identifiers: Code and logs must use explicit, fixed IDs (e.g., peer1, dmz1, nodeA). Do not use network roles as variable names. From 178bc86b17661eba33691373f4c602df856ed647 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Mon, 23 Feb 2026 15:10:37 +0700 Subject: [PATCH 21/21] chore(cli): delete dead session.rs; remove broken JSON output stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session.rs was not declared in lib.rs — completely unreachable dead code. OutputFormat::Json printed the literal string "{ json_output }" — removed the variant and the stub. Closes oopsies #14 and #15. Co-Authored-By: Claude Sonnet 4.6 --- crates/cli/src/output.rs | 13 +-- crates/cli/src/session.rs | 196 -------------------------------------- 2 files changed, 2 insertions(+), 207 deletions(-) delete mode 100644 crates/cli/src/session.rs diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index ad445d60..74793af5 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -9,7 +9,6 @@ use crate::styles::OutputStyles; pub enum OutputFormat { #[default] Plain, - Json, } pub static OUTPUT_CONFIG: LazyLock> = LazyLock::new(|| { @@ -40,16 +39,8 @@ pub struct Output { impl Output { pub fn print(&self, message: &StatusMessage) { - match self.format { - OutputFormat::Plain => { - eprintln!("{} {}", self.format_level(message.level), message.message); - } - OutputFormat::Json => { - // let json_output = serde_json::to_string(&message) - // .expect("Failed to serialize status message to JSON"); - println!("{{ json_output }}"); - } - } + let OutputFormat::Plain = self.format; + eprintln!("{} {}", self.format_level(message.level), message.message); } /// Format a message as a plain-text string without printing it. diff --git a/crates/cli/src/session.rs b/crates/cli/src/session.rs deleted file mode 100644 index 102305f5..00000000 --- a/crates/cli/src/session.rs +++ /dev/null @@ -1,196 +0,0 @@ -use std::{ - fmt::{Display, Formatter}, - net::SocketAddr, -}; - -use crate::styles::OutputStyles; - -#[derive(Default)] -pub struct Printer { - styles: OutputStyles, -} - -impl Printer { - pub fn print(&self, text: impl Into) { - let text = text.into(); - println!( - "{style}{}{style:#}", - text, - style = self.styles.get_literal() - ); - } - // pub fn print_err(&self, text: impl Into) { - // let text = text.into(); - // eprintln!("{style}{}{style:#}", text, style = self.styles.get_error()); - // } -} - -#[derive(Default)] -pub struct Console { - pub stdout: Printer, -} - -impl Console { - pub fn clear() { - print!("\x1b[H\x1b[J"); - } -} - -#[derive(Default, Debug)] -pub struct NetServer { - // _num_ctrlc: bool, - endpoint: Option, -} - -trait _StatsForNerds { - type StatsType; - - fn nerds_stats(&self) -> Self::StatsType; - - fn nerds_status(&self) -> String; -} - -impl _StatsForNerds for NetServer { - type StatsType = String; - - fn nerds_stats(&self) -> String { - "Stats: Not implemented yet.".to_string() - } - fn nerds_status(&self) -> String { - if let Some(e) = self.endpoint.as_ref() { - format!("Server is already listening on {:?}", e.local_addr()) - } else { - "Server is not listening".to_string() - } - } -} - -impl NetServer { - pub fn start( - &mut self, - config: wallhack::server::config::ServerConfig, - ) -> Result<(), wallhack::server::Error> { - tracing::trace!("config: {:?}.", config); - self.endpoint = Some(wallhack::server::create(config)?); - Ok(()) - } - - pub async fn _stop(&mut self) -> Result<(), wallhack::server::Error> { - if let Some(endp) = self.endpoint.take() { - endp.close(0u32.into(), b"stop request"); - tracing::trace!("Server stopping... Wait for idle."); - endp.wait_idle().await; - tracing::trace!("Server stopped."); - self.endpoint = None; - } - Ok(()) - } - - #[must_use] - pub fn get(&self) -> Option<&quinn::Endpoint> { - self.endpoint.as_ref() - } - - #[must_use] - pub fn has(&self) -> bool { - self.endpoint.is_some() - } - - #[must_use] - pub fn get_endpoint_status(&self) -> Status { - if let Some(e) = self.endpoint.as_ref() { - match e.local_addr() { - Ok(addr) => Status { - socket_addr: Some(DisplayableSocketAddr(addr)), - }, - Err(_) => Status { socket_addr: None }, - } - } else { - Status { socket_addr: None } - } - } -} - -pub struct Status { - socket_addr: Option, -} - -impl Display for Status { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - if let Some(addr) = &self.socket_addr { - write!(f, "Listening on {addr}",) - } else { - write!(f, "Not listening") - } - } -} - -pub struct DisplayableSocketAddr(SocketAddr); - -impl Display for DisplayableSocketAddr { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -pub struct CliSession { - pub console: Console, - pub server: NetServer, -} - -impl _StatsForNerds for CliSession { - type StatsType = String; - - fn nerds_stats(&self) -> Self::StatsType { - self.server.nerds_stats() - } - - fn nerds_status(&self) -> String { - self.server.nerds_status() - } -} - -#[cfg(test)] -mod tests { - - use super::*; - - #[test] - fn test_cli_session() { - let cli_session = CliSession { - console: Console::default(), - server: NetServer::default(), - }; - assert_eq!(cli_session.nerds_status(), "Server is not listening"); - // Note: This test only ensures the method runs without panicking. - } - - #[test] - fn test_printer_print() { - let printer = Printer::default(); - printer.print("Hello, world!"); - // Note: This test only ensures the method runs without panicking. - } - - /* #[test] - fn test_printer_print_err() { - let printer = Printer; - printer.print_err("Test only! Please ignore"); - // Note: This test only ensures the method runs without panicking. - } */ - - #[test] - fn test_console_clear() { - // let console = Console::default(); - Console::clear(); - // Note: This test only ensures the method runs without panicking. - } - - #[test] - fn test_initial_state() { - let state = NetServer::default(); - // assert!(state.workspace.is_none()); - // assert!(!state.num_ctrlc); - assert!(state.endpoint.is_none()); - } -}