diff --git a/.gitignore b/.gitignore index 374e89f..4d8035a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ server_output.txt /certs /benchmark/*/results/** !/benchmark/*/results/**/ -!/benchmark/*/results/**/*.png \ No newline at end of file +!/benchmark/*/results/**/*.png +.codegraph/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b23feee..a97abdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project are documented in this file. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### 2026-05-06 + +- Added embeddable client APIs: `client::run_async_with_shutdown` and + `RusnelHandle` with `start`, `stop`, `state`, and `subscribe`, so async hosts + can control a Rusnel client without spawning the CLI process. +- Added server-assigned dynamic ports for reverse TCP and SOCKS5 listeners when + the client requests local port `0`, including an end-to-end reverse SOCKS5 + curl test. + ## [0.11.2] - 2026-05-06 ### Fixed diff --git a/README.md b/README.md index 1fe3a4d..092635d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,37 @@ On Windows the admin HTTP API and the `rusnel ctl` subcommand are not available — both are Unix-socket-based. Tunnels, TLS, and embedded credentials work the same as on Unix. +## Library integration + +Rusnel can be embedded directly in async hosts that need programmatic client +lifecycle control, such as Tauri apps, daemons, or mobile bridges. Use the +native [`ClientConfig`](src/lib.rs) and [`RusnelHandle`](src/client/handle.rs) +instead of spawning the CLI process. + +```rust +use rusnel::{RusnelEvent, RusnelHandle}; + +# async fn run(config: rusnel::ClientConfig) -> Result<(), Box> { +let handle = RusnelHandle::new(); +let mut events = handle.subscribe(); + +handle.start(config).await?; + +while let Ok(event) = events.recv().await { + if matches!(event, RusnelEvent::Exited { .. }) { + break; + } +} + +handle.stop().await?; +# Ok(()) +# } +``` + +For lower-level integrations, `rusnel::client::run_async_with_shutdown(config, +shutdown_rx)` exposes the same client run loop with a caller-owned shutdown +receiver while preserving the CLI `Ctrl+C` path. + ### Docker Multi-arch images (`linux/amd64`, `linux/arm64`) are published to GHCR: @@ -198,6 +229,16 @@ servers reachable via a v6-preferring resolver still connect within ~250 ms. Server-side resources (including reverse-tunnel listeners) are released the moment the QUIC connection drops. +Reverse TCP and SOCKS5 listeners may use local port `0` to ask the server to +bind an OS-assigned free port, avoiding fixed-port conflicts between clients: + +```bash +rusnel client --tls-fingerprint sha256:abcd... tunnel.example.com:8080 R:0.0.0.0:0:socks +``` + +The accepted tunnel log reports the assigned listener port, for example +`R:49152=>socks`. + ## Authentication Both the server and the client require an explicit TLS-mode flag — there is diff --git a/src/client/error.rs b/src/client/error.rs new file mode 100644 index 0000000..5749eee --- /dev/null +++ b/src/client/error.rs @@ -0,0 +1,23 @@ +//! input: std::fmt +//! output: ClientError +//! pos: embeddable client handle error boundary. + +use std::fmt; + +/// Errors returned by [`crate::client::handle::RusnelHandle`]. +#[derive(Debug)] +pub enum ClientError { + /// A previous client task is still running, so a second `start` would lose + /// lifecycle ownership. + AlreadyRunning, +} + +impl fmt::Display for ClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AlreadyRunning => write!(f, "rusnel client already running"), + } + } +} + +impl std::error::Error for ClientError {} diff --git a/src/client/handle.rs b/src/client/handle.rs new file mode 100644 index 0000000..4e01c0f --- /dev/null +++ b/src/client/handle.rs @@ -0,0 +1,210 @@ +//! input: crate::ClientConfig, client::run_async_with_shutdown, tokio, futures +//! output: RusnelHandle with start/stop/state/subscribe lifecycle control +//! pos: embeddable client API for hosts that need programmatic shutdown. + +use std::panic::AssertUnwindSafe; +use std::sync::Arc; +use std::time::Duration; + +use futures::FutureExt; +use tokio::sync::{broadcast, watch, Mutex}; +use tokio::task::JoinHandle; + +use crate::client::error::ClientError; +use crate::client::lifecycle::{transition, ExitReason, LifecycleState, RusnelEvent}; +use crate::ClientConfig; + +/// Programmatic lifecycle handle for an embedded Rusnel client. +#[derive(Clone)] +pub struct RusnelHandle { + inner: Arc, +} + +/// Shared handle state; `RusnelHandle` clones only clone this `Arc`. +struct Inner { + /// Latest lifecycle state, readable without awaiting `task`. + state_tx: watch::Sender, + /// Broadcast event stream for all subscribers. + event_tx: broadcast::Sender, + /// Current running task, protected so start/stop are mutually exclusive. + task: Mutex>, +} + +/// Spawned client task plus the shutdown sender wired to it. +struct RunningTask { + /// Join handle for the isolated client future. + handle: JoinHandle<()>, + /// Sender paired with `run_async_with_shutdown`'s external receiver. + shutdown: broadcast::Sender<()>, +} + +impl Default for RusnelHandle { + fn default() -> Self { + Self::new() + } +} + +impl RusnelHandle { + /// Creates a handle with the default event buffer capacity. + #[must_use] + pub fn new() -> Self { + Self::with_capacity(256) + } + + /// Creates a handle with a caller-selected event buffer capacity. + #[must_use] + pub fn with_capacity(event_capacity: usize) -> Self { + let (state_tx, _) = watch::channel(LifecycleState::Idle); + let (event_tx, _) = broadcast::channel(event_capacity); + + Self { + inner: Arc::new(Inner { + state_tx, + event_tx, + task: Mutex::new(None), + }), + } + } + + /// Subscribes to lifecycle events. + #[must_use] + pub fn subscribe(&self) -> broadcast::Receiver { + self.inner.event_tx.subscribe() + } + + /// Returns the latest lifecycle state snapshot. + #[must_use] + pub fn state(&self) -> LifecycleState { + self.inner.state_tx.borrow().clone() + } + + /// Starts the client task with the provided native [`ClientConfig`]. + /// + /// # Errors + /// + /// Returns [`ClientError::AlreadyRunning`] when the previous task has not + /// exited yet. + pub async fn start(&self, config: ClientConfig) -> Result<(), ClientError> { + let mut guard = self.inner.task.lock().await; + if let Some(task) = guard.as_ref() { + if !task.handle.is_finished() { + return Err(ClientError::AlreadyRunning); + } + } + + let _ = rustls::crypto::ring::default_provider().install_default(); + + let (shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1); + let state_tx = self.inner.state_tx.clone(); + let event_tx = self.inner.event_tx.clone(); + + transition(&state_tx, &event_tx, LifecycleState::Running); + let handle = tokio::spawn(async move { + run_client_session(config, shutdown_rx, state_tx, event_tx).await; + }); + + *guard = Some(RunningTask { + handle, + shutdown: shutdown_tx, + }); + Ok(()) + } + + /// Requests graceful shutdown and waits up to five seconds before aborting. + /// + /// # Errors + /// + /// This method is currently infallible; it returns `Result` so callers can + /// use one uniform async control path for start and stop. + pub async fn stop(&self) -> Result<(), ClientError> { + let task = { + let mut guard = self.inner.task.lock().await; + guard.take() + }; + let Some(task) = task else { + return Ok(()); + }; + + transition( + &self.inner.state_tx, + &self.inner.event_tx, + LifecycleState::Stopping, + ); + let _ = task.shutdown.send(()); + let abort_handle = task.handle.abort_handle(); + + match tokio::time::timeout(Duration::from_secs(5), task.handle).await { + Ok(_) => { + let current = self.inner.state_tx.borrow().clone(); + if !matches!(current, LifecycleState::Stopped { .. }) { + transition( + &self.inner.state_tx, + &self.inner.event_tx, + LifecycleState::Stopped { + reason: ExitReason::UserStopped, + }, + ); + } + } + Err(_) => { + abort_handle.abort(); + transition( + &self.inner.state_tx, + &self.inner.event_tx, + LifecycleState::Stopped { + reason: ExitReason::Error("stop timeout, hard aborted".to_string()), + }, + ); + } + } + + Ok(()) + } +} + +/// Runs one client session and maps all terminal paths to lifecycle events. +async fn run_client_session( + config: ClientConfig, + shutdown_rx: broadcast::Receiver<()>, + state_tx: watch::Sender, + event_tx: broadcast::Sender, +) { + let result = AssertUnwindSafe(crate::client::run_async_with_shutdown(config, shutdown_rx)) + .catch_unwind() + .await; + + let reason = match result { + Ok(Ok(())) => ExitReason::Clean, + Ok(Err(error)) => ExitReason::Error(error.to_string()), + Err(panic) => ExitReason::Panic( + panic + .downcast_ref::<&str>() + .map(|message| (*message).to_string()) + .or_else(|| panic.downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic".to_string()), + ), + }; + + transition( + &state_tx, + &event_tx, + LifecycleState::Stopped { + reason: reason.clone(), + }, + ); + let _ = event_tx.send(RusnelEvent::Exited { reason }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn stop_is_ok_when_idle() { + let handle = RusnelHandle::new(); + + handle.stop().await.expect("idle stop should succeed"); + + assert_eq!(handle.state(), LifecycleState::Idle); + } +} diff --git a/src/client/lifecycle.rs b/src/client/lifecycle.rs new file mode 100644 index 0000000..dc9dcb0 --- /dev/null +++ b/src/client/lifecycle.rs @@ -0,0 +1,75 @@ +//! input: serde, tokio::sync::broadcast/watch +//! output: LifecycleState, RusnelEvent, ExitReason, transition() +//! pos: embeddable client lifecycle state and event model. + +use serde::{Deserialize, Serialize}; +use tokio::sync::{broadcast, watch}; + +/// Reason why an embedded client session reached a stopped state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExitReason { + /// `run_async_with_shutdown` returned successfully. + Clean, + /// `stop` completed before the client task wrote a more specific reason. + UserStopped, + /// The client returned an error. + Error(String), + /// The client task panicked and the handle isolated the panic. + Panic(String), +} + +/// Current lifecycle state for a [`crate::client::handle::RusnelHandle`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LifecycleState { + /// The handle has not started a client task. + Idle, + /// A client task has been spawned and owns the Rusnel session. + Running, + /// `stop` has sent shutdown and is waiting for the client task to exit. + Stopping, + /// The client task has exited or was aborted after a stop timeout. + Stopped { reason: ExitReason }, +} + +impl LifecycleState { + /// Returns true while the handle owns a live client task. + #[must_use] + pub fn is_running(&self) -> bool { + matches!(self, Self::Running) + } +} + +/// Events emitted by an embedded client handle. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RusnelEvent { + /// Lifecycle state changed. + StateChange { + /// Previous state observed from the watch channel. + from: LifecycleState, + /// New state written to the watch channel. + to: LifecycleState, + }, + /// The client task exited and will not emit more lifecycle transitions. + Exited { + /// Final exit reason. + reason: ExitReason, + }, +} + +/// Writes the latest lifecycle state and broadcasts a matching event. +pub(crate) fn transition( + state_tx: &watch::Sender, + event_tx: &broadcast::Sender, + to: LifecycleState, +) { + let from = state_tx.borrow().clone(); + if from == to { + return; + } + + state_tx.send_replace(to.clone()); + let _ = event_tx.send(RusnelEvent::StateChange { from, to }); +} diff --git a/src/client/mod.rs b/src/client/mod.rs index befb82f..6f31908 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -22,6 +22,10 @@ use crate::common::tunnel::{client_send_session_hello, receive_open_conn, reply_ use crate::common::udp::{tunnel_udp_client, tunnel_udp_server}; use crate::{ClientConfig, ReconnectConfig}; +pub mod error; +pub mod handle; +pub mod lifecycle; + pub async fn run_async(config: ClientConfig) -> Result<()> { // Direct connections share QUIC endpoints across reconnects (one per // address family) so we don't pay the bind-syscall cost on every retry. @@ -57,6 +61,62 @@ pub async fn run_async(config: ClientConfig) -> Result<()> { result } +/// Run the client with a caller-owned shutdown signal. +/// +/// This is the embeddable-client entry point for async hosts such as Tauri, +/// daemons, or Android bindings. It preserves the CLI `Ctrl+C` behavior while +/// also letting the caller trigger the same internal shutdown broadcast through +/// `external_rx`. +/// +/// # Errors +/// +/// Returns any error produced while building endpoints, connecting, or running +/// the reconnect loop. +pub async fn run_async_with_shutdown( + config: ClientConfig, + mut external_rx: broadcast::Receiver<()>, +) -> Result<()> { + // Direct and proxied endpoint setup must match `run_async`, otherwise the + // library path would behave differently from the CLI path. + let mut endpoints = match &config.proxy { + None => Some(EndpointPool::new(&config)?), + Some(p) => { + info!(proxy = %p, "routing QUIC through SOCKS5 proxy"); + None + } + }; + let server_name = client_server_name(&config.tls, &config.server.host); + + let (shutdown_tx, _) = broadcast::channel::<()>(1); + + // Bridge both process-level and host-level shutdown into the same internal + // broadcast used by reconnect sleeps, active QUIC connections, and stdio + // tunnels. + let shutdown_tx_bridge = shutdown_tx.clone(); + tokio::spawn(async move { + tokio::select! { + res = signal::ctrl_c() => { + if res.is_ok() { + info!("shutdown signal received (ctrl-c)"); + let _ = shutdown_tx_bridge.send(()); + } + } + _ = external_rx.recv() => { + info!("shutdown signal received (external)"); + let _ = shutdown_tx_bridge.send(()); + } + } + }); + + let result = run_with_reconnect(endpoints.as_mut(), &server_name, &config, &shutdown_tx).await; + + if let Some(pool) = endpoints.as_ref() { + pool.wait_idle().await; + } + debug!("client run loop exited"); + result +} + /// Lazily-initialized pair of QUIC endpoints, one per address family. Happy /// Eyeballs may need to race a v4 and v6 connect attempt simultaneously, but /// each `quinn::Endpoint` owns a single UDP socket bound to one family, so we @@ -331,14 +391,16 @@ async fn run_connection( // Negotiate the whole tunnel set in one shot. If the server // rejects (policy violation, version mismatch, …) we surface that // as a Disconnect — the reconnect loop will keep retrying. - let tunnel_ids = match send_session_hello(&connection, &config.remotes).await { - Ok(ids) => ids, + let (tunnel_ids, assigned_ports) = match send_session_hello(&connection, &config.remotes).await + { + Ok(result) => result, Err(e) => { return SessionOutcome::Disconnected(format!("session hello failed: {e}")); } }; + let remotes = apply_assigned_ports(&config.remotes, &assigned_ports); info!(count = tunnel_ids.len(), "session established"); - for (remote, tunnel_id) in config.remotes.iter().zip(tunnel_ids.iter().copied()) { + for (remote, tunnel_id) in remotes.iter().zip(tunnel_ids.iter().copied()) { let dir = if matches!(remote.direction, Direction::Reverse) { "reverse" } else { @@ -353,7 +415,7 @@ async fn run_connection( tunnel_ids .iter() .copied() - .zip(config.remotes.iter().cloned()) + .zip(remotes.iter().cloned()) .collect(), ); @@ -362,7 +424,7 @@ async fn run_connection( // Spawn a per-forward-remote listener task. Reverse remotes have // nothing to bind on the client — their listener runs on the // server, and conns flow back through the accept loop below. - for (remote, tunnel_id) in config.remotes.iter().zip(tunnel_ids.iter().copied()) { + for (remote, tunnel_id) in remotes.iter().zip(tunnel_ids.iter().copied()) { if matches!(remote.direction, Direction::Reverse) { continue; } @@ -444,7 +506,7 @@ async fn run_connection( async fn send_session_hello( quic_connection: &Connection, remotes: &[RemoteRequest], -) -> Result> { +) -> Result<(Vec, Vec>)> { let (mut send, mut recv) = quic_connection.open_bi().await?; let hello = SessionHello { remotes: remotes.to_vec(), @@ -452,6 +514,33 @@ async fn send_session_hello( client_send_session_hello(&hello, &mut send, &mut recv).await } +fn apply_assigned_ports( + remotes: &[RemoteRequest], + assigned_ports: &[Option], +) -> Vec { + remotes + .iter() + .cloned() + .zip(assigned_ports.iter().copied()) + .map(|(mut remote, assigned)| { + if let Some(port) = assigned { + replace_local_port(&mut remote, port); + } + remote + }) + .collect() +} + +fn replace_local_port(remote: &mut RemoteRequest, port: u16) { + match &mut remote.kind { + RemoteKind::Tcp { local, .. } + | RemoteKind::Udp { local, .. } + | RemoteKind::Socks5 { local } => { + *local = SocketAddr::new(local.ip(), port); + } + } +} + /// Drive the local listener for one *forward* tunnel. Each accepted /// local connection opens a fresh bi-stream and announces itself with /// an [`OpenConn`] keyed by `tunnel_id`; from there the existing @@ -470,10 +559,10 @@ async fn handle_forward_tunnel( } match &remote.kind { RemoteKind::Socks5 { .. } => { - tunnel_socks_client(quic_connection, remote, None, tunnel_id).await? + tunnel_socks_client(quic_connection, remote, None, tunnel_id, None).await? } RemoteKind::Tcp { .. } => { - tunnel_tcp_client(quic_connection, remote, None, tunnel_id).await? + tunnel_tcp_client(quic_connection, remote, None, tunnel_id, None).await? } RemoteKind::Udp { .. } => { tunnel_udp_client(quic_connection, remote, None, tunnel_id).await? diff --git a/src/common/remote.rs b/src/common/remote.rs index 42ac52f..2423650 100644 --- a/src/common/remote.rs +++ b/src/common/remote.rs @@ -221,11 +221,16 @@ pub struct SessionHello { impl SerdeHelper for SessionHello {} /// Server's reply to [`SessionHello`]. On success, `tunnel_ids[i]` is -/// the server-assigned id for `hello.remotes[i]`. On failure, the -/// server closes the connection. +/// the server-assigned id for `hello.remotes[i]`, and `assigned_ports[i]` +/// carries a server-selected reverse listener port when the client requested +/// local port `0`. On failure, the server closes the connection. #[derive(Serialize, Deserialize, Debug)] pub enum SessionHelloResponse { - Ok { tunnel_ids: Vec }, + Ok { + tunnel_ids: Vec, + #[serde(default)] + assigned_ports: Vec>, + }, Failed(String), } @@ -693,6 +698,14 @@ mod tests { assert_eq!(r.local_socket_addr().port(), 5000); } + #[test] + fn reverse_socks_dynamic_local_port() { + let r = parse("R:0.0.0.0:0:socks"); + assert!(r.is_reversed()); + assert!(r.is_socks()); + assert_eq!(r.local_socket_addr(), SocketAddr::new(ip("0.0.0.0"), 0)); + } + #[test] fn rejects_unknown_protocol() { let err = RemoteRequest::from_str("1.1.1.1:53/sctp").unwrap_err(); diff --git a/src/common/socks.rs b/src/common/socks.rs index 1489cd7..f9db429 100644 --- a/src/common/socks.rs +++ b/src/common/socks.rs @@ -36,9 +36,13 @@ pub async fn tunnel_socks_client( remote: RemoteRequest, handle: TunnelHandleOpt, tunnel_id: u64, + prebound: Option, ) -> Result<()> { let local_addr = remote.local_socket_addr(); - let listener = TcpListener::bind(local_addr).await?; + let listener = match prebound { + Some(listener) => listener, + None => TcpListener::bind(local_addr).await?, + }; info!(addr = %local_addr, proto = "socks5", "listening"); let local_counter = AtomicUsize::new(0); diff --git a/src/common/tcp.rs b/src/common/tcp.rs index 00f1805..75eca02 100644 --- a/src/common/tcp.rs +++ b/src/common/tcp.rs @@ -316,12 +316,16 @@ pub async fn tunnel_tcp_client( remote: RemoteRequest, handle: TunnelHandleOpt, tunnel_id: u64, + prebound: Option, ) -> Result<()> { // Use SocketAddr's Display so IPv6 literals come out bracketed // (`[::1]:8080`) — a manual `format!("{ip}:{port}")` on an IPv6 // `IpAddr` produces `::1:8080`, which `TcpListener::bind` rejects. let local_addr = remote.local_socket_addr(); - let listener = TcpListener::bind(local_addr).await?; + let listener = match prebound { + Some(listener) => listener, + None => TcpListener::bind(local_addr).await?, + }; info!(addr = %local_addr, "listening"); // Local fallback counter for log-only correlation when admin diff --git a/src/common/tunnel.rs b/src/common/tunnel.rs index 02e7b9a..c1ffe9e 100644 --- a/src/common/tunnel.rs +++ b/src/common/tunnel.rs @@ -67,17 +67,21 @@ async fn read_framed(recv: &mut RecvStream) -> Result { /// Client side of the hello: send the full tunnel-declaration batch /// and wait for the server's verdict. On success, returns the list of -/// server-assigned `tunnel_id`s in the same order as `hello.remotes`. +/// server-assigned `tunnel_id`s and any server-assigned reverse listener +/// ports in the same order as `hello.remotes`. pub async fn client_send_session_hello( hello: &SessionHello, send: &mut SendStream, recv: &mut RecvStream, -) -> Result> { +) -> Result<(Vec, Vec>)> { debug!(remotes = hello.remotes.len(), "sending session hello"); write_framed(send, hello).await?; send.shutdown().await?; match read_framed::(recv).await? { - SessionHelloResponse::Ok { tunnel_ids } => { + SessionHelloResponse::Ok { + tunnel_ids, + assigned_ports, + } => { if tunnel_ids.len() != hello.remotes.len() { return Err(anyhow!( "server returned {} tunnel ids for {} remotes", @@ -85,8 +89,20 @@ pub async fn client_send_session_hello( hello.remotes.len() )); } + if !assigned_ports.is_empty() && assigned_ports.len() != hello.remotes.len() { + return Err(anyhow!( + "server returned {} assigned ports for {} remotes", + assigned_ports.len(), + hello.remotes.len() + )); + } + let assigned_ports = if assigned_ports.is_empty() { + vec![None; hello.remotes.len()] + } else { + assigned_ports + }; debug!(count = tunnel_ids.len(), "session hello accepted"); - Ok(tunnel_ids) + Ok((tunnel_ids, assigned_ports)) } SessionHelloResponse::Failed(reason) => Err(anyhow!("server rejected session: {reason}")), } diff --git a/src/lib.rs b/src/lib.rs index b9c0348..bfa4dda 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,10 @@ pub mod ctl; pub mod embedded; pub mod server; +pub use client::error::ClientError; +pub use client::handle::RusnelHandle; +pub use client::lifecycle::{ExitReason, LifecycleState, RusnelEvent}; + #[derive(Debug)] pub struct ServerConfig { pub host: IpAddr, diff --git a/src/server/mod.rs b/src/server/mod.rs index fb5c63a..b547156 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -5,14 +5,16 @@ pub mod admin; pub mod state; +use std::net::SocketAddr; #[cfg(unix)] use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; -use anyhow::Result; +use anyhow::{Context, Result}; use quinn::{Connection, ConnectionError, VarInt}; +use tokio::net::TcpListener; use tokio::signal; use tokio::sync::Semaphore; use tokio::task::JoinSet; @@ -221,7 +223,8 @@ async fn handle_client_connection( // soon as the hello is accepted, before any conn flows. Forward // tunnels are passive on the server side; their conns arrive as // OpenConn frames on the per-conn loop below. - for tunnel in ®istered_tunnels { + for prepared in registered_tunnels { + let tunnel = prepared.entry; let dir = match tunnel.direction { Direction::Forward => "forward", Direction::Reverse => "reverse", @@ -237,6 +240,7 @@ async fn handle_client_connection( connection.clone(), state.clone(), tunnel.clone(), + prepared.prebound, &mut tunnels, ); } @@ -328,7 +332,7 @@ async fn perform_session_hello( client: &Arc, allow_reverse: bool, allow_socks: bool, -) -> Result>> { +) -> Result> { let (mut send, mut recv) = connection.accept_bi().await?; let hello = server_receive_session_hello(&mut recv).await?; @@ -338,10 +342,110 @@ async fn perform_session_hello( return Err(anyhow::anyhow!(reason)); } - let tunnels = state.register_tunnels(client, &hello.remotes); + let prepared_remotes = prepare_reverse_dynamic_ports(hello.remotes).await?; + let remotes: Vec = prepared_remotes + .iter() + .map(|remote| remote.request.clone()) + .collect(); + let assigned_ports: Vec> = prepared_remotes + .iter() + .map(|remote| remote.assigned_port) + .collect(); + let prebound_listeners: Vec> = prepared_remotes + .into_iter() + .map(|remote| remote.prebound) + .collect(); + + let tunnels = state.register_tunnels(client, &remotes); let tunnel_ids: Vec = tunnels.iter().map(|t| t.id).collect(); - server_reply_session_hello(&mut send, &SessionHelloResponse::Ok { tunnel_ids }).await?; - Ok(tunnels) + server_reply_session_hello( + &mut send, + &SessionHelloResponse::Ok { + tunnel_ids, + assigned_ports, + }, + ) + .await?; + + Ok(tunnels + .into_iter() + .zip(prebound_listeners) + .map(|(entry, prebound)| PreparedTunnel { entry, prebound }) + .collect()) +} + +struct PreparedRemote { + request: RemoteRequest, + assigned_port: Option, + prebound: Option, +} + +struct PreparedTunnel { + entry: Arc, + prebound: Option, +} + +async fn prepare_reverse_dynamic_ports(remotes: Vec) -> Result> { + let mut prepared = Vec::with_capacity(remotes.len()); + + for remote in remotes { + if !matches!(remote.direction, Direction::Reverse) || remote.local_socket_addr().port() != 0 + { + prepared.push(PreparedRemote { + request: remote, + assigned_port: None, + prebound: None, + }); + continue; + } + + prepared.push(prebind_dynamic_reverse_remote(remote).await?); + } + + Ok(prepared) +} + +async fn prebind_dynamic_reverse_remote(remote: RemoteRequest) -> Result { + match remote.kind { + RemoteKind::Tcp { + local, + remote: target, + } => { + let (local, listener) = bind_dynamic_tcp_listener(local).await?; + Ok(PreparedRemote { + request: RemoteRequest::new( + Direction::Reverse, + RemoteKind::Tcp { + local, + remote: target, + }, + ), + assigned_port: Some(local.port()), + prebound: Some(listener), + }) + } + RemoteKind::Socks5 { local } => { + let (local, listener) = bind_dynamic_tcp_listener(local).await?; + Ok(PreparedRemote { + request: RemoteRequest::new(Direction::Reverse, RemoteKind::Socks5 { local }), + assigned_port: Some(local.port()), + prebound: Some(listener), + }) + } + RemoteKind::Udp { local, remote } => Ok(PreparedRemote { + request: RemoteRequest::new(Direction::Reverse, RemoteKind::Udp { local, remote }), + assigned_port: None, + prebound: None, + }), + } +} + +async fn bind_dynamic_tcp_listener(requested: SocketAddr) -> Result<(SocketAddr, TcpListener)> { + let listener = TcpListener::bind(requested) + .await + .with_context(|| format!("failed to bind dynamic reverse listener {requested}"))?; + let assigned_port = listener.local_addr()?.port(); + Ok((SocketAddr::new(requested.ip(), assigned_port), listener)) } /// Static validation of a hello batch against the server's policy. @@ -367,6 +471,7 @@ fn spawn_reverse_handler( connection: Connection, state: ServerState, tunnel: Arc, + prebound: Option, tasks: &mut JoinSet<()>, ) { let handle = Arc::new(TunnelHandle::new(state, tunnel.clone())); @@ -385,13 +490,14 @@ fn spawn_reverse_handler( let request = RemoteRequest::new(tunnel.direction, tunnel.kind.clone()); let result = match &tunnel.kind { RemoteKind::Tcp { .. } => { - tunnel_tcp_client(connection, request, Some(handle), tunnel.id).await + tunnel_tcp_client(connection, request, Some(handle), tunnel.id, prebound).await } RemoteKind::Udp { .. } => { tunnel_udp_client(connection, request, Some(handle), tunnel.id).await } RemoteKind::Socks5 { .. } => { - tunnel_socks_client(connection, request, Some(handle), tunnel.id).await + tunnel_socks_client(connection, request, Some(handle), tunnel.id, prebound) + .await } }; if let Err(e) = result { diff --git a/tests/embeddable_client.rs b/tests/embeddable_client.rs new file mode 100644 index 0000000..01649c7 --- /dev/null +++ b/tests/embeddable_client.rs @@ -0,0 +1,383 @@ +//! input: rusnel native ClientConfig/RusnelHandle, localhost TCP echo servers +//! output: end-to-end coverage for the embeddable client lifecycle API +//! pos: integration tests for library embedding without the external controller crate. + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::process::Command; +use std::str::FromStr; +use std::sync::OnceLock; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use rusnel::common::quic::Congestion; +use rusnel::common::remote::RemoteRequest; +use rusnel::common::tls::{ClientTlsConfig, ServerTlsConfig}; +use rusnel::{ + ClientConfig, ExitReason, LifecycleState, ReconnectConfig, RusnelEvent, RusnelHandle, + ServerConfig, ServerEndpoint, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::mpsc; +use tokio::time::Instant; +use tracing::field::{Field, Visit}; +use tracing::Subscriber; +use tracing_subscriber::layer::Context as LayerContext; +use tracing_subscriber::prelude::*; +use tracing_subscriber::Layer; + +/// Holds a spawned server task and aborts it when the test exits. +struct ServerGuard { + /// The server runs forever unless the test aborts it. + handle: tokio::task::JoinHandle<()>, +} + +impl Drop for ServerGuard { + fn drop(&mut self) { + self.handle.abort(); + } +} + +/// Test-only tracing layer that extracts client-side tunnel registration specs. +struct TunnelSpecLayer; + +static TUNNEL_SPEC_TX: OnceLock> = OnceLock::new(); + +impl Layer for TunnelSpecLayer +where + S: Subscriber, +{ + fn on_event(&self, event: &tracing::Event<'_>, _ctx: LayerContext<'_, S>) { + if event.metadata().target() != "rusnel::client" { + return; + } + + let mut visitor = TunnelSpecVisitor::default(); + event.record(&mut visitor); + if visitor.message.as_deref() == Some("tunnel registered") { + if let Some(spec) = visitor.spec { + if let Some(tx) = TUNNEL_SPEC_TX.get() { + let _ = tx.send(spec); + } + } + } + } +} + +/// Minimal tracing field visitor for the fields this test needs. +#[derive(Default)] +struct TunnelSpecVisitor { + /// Formatted tracing message field. + message: Option, + /// Remote spec emitted by Rusnel after applying assigned ports. + spec: Option, +} + +impl Visit for TunnelSpecVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + let value = format!("{value:?}").trim_matches('"').to_string(); + match field.name() { + "message" => self.message = Some(value), + "spec" => self.spec = Some(value), + _ => {} + } + } +} + +/// Installs a process-wide subscriber so spawned runtime threads are covered. +fn capture_tunnel_specs() -> mpsc::UnboundedReceiver { + let (tx, rx) = mpsc::unbounded_channel(); + let _ = TUNNEL_SPEC_TX.set(tx); + let subscriber = tracing_subscriber::registry().with(TunnelSpecLayer); + let _ = tracing::subscriber::set_global_default(subscriber); + rx +} + +/// Returns an available localhost TCP port for short-lived integration tests. +fn free_port() -> Result { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)) + .context("failed to reserve a local tcp port")?; + Ok(listener + .local_addr() + .context("failed to read reserved local address")? + .port()) +} + +/// Starts a TCP echo server and returns its listening port. +async fn spawn_echo() -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .context("failed to bind echo server")?; + let port = listener + .local_addr() + .context("failed to read echo server address")? + .port(); + + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + tokio::spawn(async move { + let mut buffer = [0u8; 1024]; + loop { + match stream.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => { + if stream.write_all(&buffer[..read]).await.is_err() { + break; + } + } + } + } + }); + } + }); + + Ok(port) +} + +/// Starts a tiny HTTP server used as the SOCKS5 target for curl. +async fn spawn_http_server() -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .context("failed to bind http test server")?; + let port = listener + .local_addr() + .context("failed to read http test server address")? + .port(); + + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + tokio::spawn(async move { + let mut buffer = [0u8; 1024]; + let _ = stream.read(&mut buffer).await; + let response = concat!( + "HTTP/1.1 200 OK\r\n", + "Content-Length: 15\r\n", + "Connection: close\r\n", + "\r\n", + "rusnel-socks-ok" + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + + Ok(port) +} + +/// Connects to a tunnel entry with retries because listeners come up async. +async fn tcp_roundtrip(port: u16, payload: &[u8]) -> Result> { + let deadline = Instant::now() + Duration::from_secs(15); + let mut last_error = None; + + while Instant::now() < deadline { + match TcpStream::connect(("127.0.0.1", port)).await { + Ok(mut stream) => { + stream + .write_all(payload) + .await + .context("failed to write payload through tunnel")?; + let mut response = vec![0u8; payload.len()]; + stream + .read_exact(&mut response) + .await + .context("failed to read payload through tunnel")?; + return Ok(response); + } + Err(error) => { + last_error = Some(error); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + + Err(anyhow!( + "connect to tunnel entry 127.0.0.1:{port} timed out: {last_error:?}" + )) +} + +/// Starts a local insecure Rusnel server for a handle integration test. +async fn spawn_server(port: u16) -> ServerGuard { + spawn_server_with_socks(port, false).await +} + +/// Starts a local insecure Rusnel server with explicit SOCKS policy. +async fn spawn_server_with_socks(port: u16, allow_socks: bool) -> ServerGuard { + let config = ServerConfig { + host: IpAddr::V4(Ipv4Addr::LOCALHOST), + port, + allow_reverse: true, + allow_socks, + tls: ServerTlsConfig::Insecure, + congestion: Congestion::default(), + max_connections: None, + admin_socket: None::, + }; + + let handle = tokio::spawn(async move { + let _ = rusnel::server::run_async(config).await; + }); + ServerGuard { handle } +} + +/// Waits for the dynamically assigned reverse SOCKS5 listener port. +async fn wait_for_socks_port(specs: &mut mpsc::UnboundedReceiver) -> Result { + let deadline = Instant::now() + Duration::from_secs(15); + + while Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + let spec = tokio::time::timeout(remaining, specs.recv()) + .await + .context("timed out waiting for tunnel registration")? + .context("tunnel registration channel closed")?; + + if let Some(port) = parse_socks_spec_port(&spec) { + return Ok(port); + } + } + + Err(anyhow!("dynamic reverse socks port was not announced")) +} + +/// Parses display specs like `R:49152=>socks`. +fn parse_socks_spec_port(spec: &str) -> Option { + let after_prefix = spec.strip_prefix("R:").unwrap_or(spec); + let (port, target) = after_prefix.split_once("=>")?; + if target != "socks" { + return None; + } + port.parse().ok() +} + +/// Builds the native client config used by the embeddable handle. +fn client_config(server_port: u16, remotes: Vec) -> ClientConfig { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), server_port); + ClientConfig { + server: ServerEndpoint { + addrs: vec![addr], + host: addr.ip().to_string(), + }, + remotes, + tls: ClientTlsConfig::Insecure, + congestion: Congestion::default(), + reconnect: ReconnectConfig::default(), + proxy: None, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn handle_runs_forward_and_reverse_tunnels_then_stops() -> Result<()> { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let forward_target = spawn_echo().await?; + let reverse_target = spawn_echo().await?; + let server_port = free_port()?; + let forward_entry = free_port()?; + let reverse_entry = free_port()?; + let _server = spawn_server(server_port).await; + tokio::time::sleep(Duration::from_millis(500)).await; + + let forward = RemoteRequest::from_str(&format!( + "127.0.0.1:{forward_entry}:127.0.0.1:{forward_target}" + )) + .context("failed to parse forward remote")?; + let reverse = RemoteRequest::from_str(&format!( + "R:127.0.0.1:{reverse_entry}:127.0.0.1:{reverse_target}" + )) + .context("failed to parse reverse remote")?; + + let handle = RusnelHandle::new(); + let mut events = handle.subscribe(); + assert_eq!(handle.state(), LifecycleState::Idle); + + handle + .start(client_config(server_port, vec![forward, reverse])) + .await + .context("embedded client failed to start")?; + assert!(handle.state().is_running()); + + let forward_echo = tcp_roundtrip(forward_entry, b"hello-forward").await?; + assert_eq!(forward_echo, b"hello-forward"); + + let reverse_echo = tcp_roundtrip(reverse_entry, b"hello-reverse").await?; + assert_eq!(reverse_echo, b"hello-reverse"); + + handle + .stop() + .await + .context("embedded client failed to stop")?; + assert!(matches!( + handle.state(), + LifecycleState::Stopped { + reason: ExitReason::Clean | ExitReason::UserStopped + } + )); + + let mut saw_stopped = false; + while let Ok(event) = events.try_recv() { + if matches!( + event, + RusnelEvent::StateChange { + to: LifecycleState::Stopped { .. }, + .. + } + ) { + saw_stopped = true; + } + } + assert!(saw_stopped, "stop should publish a stopped state event"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reverse_socks5_zero_port_is_assigned_and_works_with_curl() -> Result<()> { + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut tunnel_specs = capture_tunnel_specs(); + + let target_port = spawn_http_server().await?; + let server_port = free_port()?; + let _server = spawn_server_with_socks(server_port, true).await; + tokio::time::sleep(Duration::from_millis(500)).await; + + let reverse_socks = RemoteRequest::from_str("R:0.0.0.0:0:socks") + .context("failed to parse dynamic reverse socks remote")?; + let handle = RusnelHandle::new(); + + handle + .start(client_config(server_port, vec![reverse_socks])) + .await + .context("embedded client failed to start dynamic reverse socks")?; + + let socks_port = wait_for_socks_port(&mut tunnel_specs).await?; + assert_ne!(socks_port, 0, "server must announce a concrete socks port"); + + let output = Command::new("curl.exe") + .args([ + "--silent", + "--show-error", + "--fail", + "--max-time", + "10", + "--socks5-hostname", + &format!("127.0.0.1:{socks_port}"), + &format!("http://127.0.0.1:{target_port}/"), + ]) + .output() + .context("failed to execute curl.exe")?; + + assert!( + output.status.success(), + "curl failed: status={:?}, stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "rusnel-socks-ok"); + + handle + .stop() + .await + .context("embedded client failed to stop")?; + + Ok(()) +} diff --git a/tests/reconnect.rs b/tests/reconnect.rs index ba9fc41..c3bba0c 100644 --- a/tests/reconnect.rs +++ b/tests/reconnect.rs @@ -138,8 +138,14 @@ async fn run_test_session(connection: Connection) { tunnel_ids.push(id); tunnels.insert(id, r.clone()); } - if let Err(e) = - server_reply_session_hello(&mut hello_send, &SessionHelloResponse::Ok { tunnel_ids }).await + if let Err(e) = server_reply_session_hello( + &mut hello_send, + &SessionHelloResponse::Ok { + tunnel_ids, + assigned_ports: vec![None], + }, + ) + .await { info!("hello reply failed: {e}"); return;