Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ server_output.txt
/certs
/benchmark/*/results/**
!/benchmark/*/results/**/
!/benchmark/*/results/**/*.png
!/benchmark/*/results/**/*.png
.codegraph/
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error>> {
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:
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src/client/error.rs
Original file line number Diff line number Diff line change
@@ -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 {}
210 changes: 210 additions & 0 deletions src/client/handle.rs
Original file line number Diff line number Diff line change
@@ -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<Inner>,
}

/// Shared handle state; `RusnelHandle` clones only clone this `Arc`.
struct Inner {
/// Latest lifecycle state, readable without awaiting `task`.
state_tx: watch::Sender<LifecycleState>,
/// Broadcast event stream for all subscribers.
event_tx: broadcast::Sender<RusnelEvent>,
/// Current running task, protected so start/stop are mutually exclusive.
task: Mutex<Option<RunningTask>>,
}

/// 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<RusnelEvent> {
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<LifecycleState>,
event_tx: broadcast::Sender<RusnelEvent>,
) {
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::<String>().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);
}
}
75 changes: 75 additions & 0 deletions src/client/lifecycle.rs
Original file line number Diff line number Diff line change
@@ -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<LifecycleState>,
event_tx: &broadcast::Sender<RusnelEvent>,
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 });
}
Loading