feat(napi): add TLS and iroh transport support to serve() and connect()

This commit is contained in:
2026-06-03 05:58:05 +00:00
parent 053ace6fcc
commit 150b1f3ae5
5 changed files with 255 additions and 20 deletions

4
Cargo.lock generated
View File

@@ -5637,10 +5637,14 @@ name = "wraith-napi"
version = "0.1.0"
dependencies = [
"async-trait",
"iroh",
"napi",
"napi-derive",
"russh",
"rustls-pemfile",
"rustls-pki-types",
"tokio",
"url",
"wraith-core",
]

View File

@@ -165,8 +165,6 @@ server.onConnection((event) => {
});
```
> **Note:** The NAPI `serve()` currently supports TCP transport only. TLS and iroh transports will be added in a future release.
## Status and stability
This is **alpha software**. While it depends on well-established libraries (russh, tokio, rustls, iroh) for SSH, async I/O, TLS, and QUIC respectively, the integration layer that ties them together has not been battle-tested. Potential concerns include:

View File

@@ -15,4 +15,8 @@ napi = { version = "3", features = ["async", "error_anyhow"] }
napi-derive = "3"
tokio = { version = "1", features = ["io-util", "sync", "rt", "macros", "net", "time", "signal"] }
russh = "0.49"
async-trait = "0.1"
async-trait = "0.1"
rustls-pemfile = "2"
rustls-pki-types = "1"
iroh = "0.34"
url = "2"

View File

@@ -15,7 +15,7 @@ use tokio::sync::Mutex;
use wraith_core::auth::client_auth::{ClientAuthConfig, ClientHandler};
use wraith_core::auth::keys::KeySource;
use wraith_core::transport::{TcpTransport, TlsTransport, Transport};
use wraith_core::transport::{IrohTransport, TcpTransport, TlsTransport, Transport};
const DEFAULT_HOST: &str = "wraith-control";
const DEFAULT_PORT: u32 = 0;
@@ -153,10 +153,49 @@ pub async fn connect(options: WraithConnectOptions) -> Result<WraithStream> {
})?
}
"iroh" => {
return Err(Error::new(
Status::GenericFailure,
"iroh transport is not yet supported in napi connect()".to_string(),
));
let peer = options.peer.as_ref().ok_or_else(|| {
Error::new(Status::InvalidArg, "peer is required for iroh transport")
})?;
let node_id: iroh::NodeId = peer.parse().map_err(|e| {
Error::new(
Status::InvalidArg,
format!("invalid iroh peer ID '{}': {}", peer, e),
)
})?;
let relay_url: Option<iroh::RelayUrl> = match options.iroh_relay.as_deref() {
Some(u) => Some(u.parse().map_err(|e| {
Error::new(Status::InvalidArg, format!("invalid iroh relay URL: {}", e))
})?),
None => None,
};
let proxy_url: Option<url::Url> = match options.proxy.as_deref() {
Some(u) => Some(u.parse().map_err(|e| {
Error::new(Status::InvalidArg, format!("invalid proxy URL: {}", e))
})?),
None => None,
};
let transport = IrohTransport::new(node_id, relay_url, proxy_url)
.await
.map_err(|e| {
Error::new(
Status::GenericFailure,
format!("iroh endpoint setup failed: {}", e),
)
})?;
let stream = transport.connect().await.map_err(|e| {
Error::new(
Status::GenericFailure,
format!("iroh connect failed: {}", e),
)
})?;
client::connect_stream(config, stream, handler)
.await
.map_err(|e| {
Error::new(
Status::GenericFailure,
format!("ssh handshake failed: {}", e),
)
})?
}
_ => {
return Err(Error::new(

View File

@@ -1,8 +1,7 @@
//! NAPI `serve()` function and `WraithServer` type.
//!
//! Starts a TCP-based SSH server that emits new channel streams via a
//! `ThreadsafeFunction` callback. Currently supports TCP transport only;
//! TLS and iroh will be added in a future release.
//! Starts an SSH server that emits new channel streams via a
//! `ThreadsafeFunction` callback. Supports TCP, TLS, and iroh transports.
use std::net::SocketAddr;
use std::sync::Arc;
@@ -32,6 +31,7 @@ pub struct WraithServeOptions {
pub acme_domain: Option<String>,
pub listen: Option<String>,
pub iroh_relay: Option<String>,
pub proxy: Option<String>,
}
fn resolve_key_source(
@@ -257,6 +257,7 @@ type ServerTsfn = ThreadsafeFunction<ConnectionEventWrapper, (), ConnectionEvent
pub struct WraithServer {
shutdown_tx: tokio::sync::watch::Sender<bool>,
listen_addr: String,
endpoint_id: Option<String>,
on_connection_tsfn: Arc<Mutex<Option<ServerTsfn>>>,
}
@@ -330,6 +331,11 @@ impl WraithServer {
pub fn listen_addr(&self) -> napi::Result<String> {
Ok(self.listen_addr.clone())
}
#[napi(getter, ts_return_type = "string | null")]
pub fn endpoint_id(&self) -> napi::Result<Option<String>> {
Ok(self.endpoint_id.clone())
}
}
#[napi]
@@ -429,6 +435,7 @@ pub async fn serve(options: WraithServeOptions) -> napi::Result<WraithServer> {
connection_limiter,
shutdown_rx,
tsfn_for_loop,
"tcp".to_string(),
)
.await;
});
@@ -436,17 +443,199 @@ pub async fn serve(options: WraithServeOptions) -> napi::Result<WraithServer> {
Ok(WraithServer {
shutdown_tx,
listen_addr: actual_listen,
endpoint_id: None,
on_connection_tsfn: tsfn_holder,
})
}
ServeTransportMode::Tls => {
use wraith_core::transport::TlsAcceptor;
let addr = parse_addr(listen_addr_str)?;
let tls_cert_path = options.tls_cert.as_ref().ok_or_else(|| {
napi::Error::new(
napi::Status::InvalidArg,
"tlsCert is required for TLS transport".to_string(),
)
})?;
let tls_key_path = options.tls_key.as_ref().ok_or_else(|| {
napi::Error::new(
napi::Status::InvalidArg,
"tlsKey is required for TLS transport".to_string(),
)
})?;
let cert_data = std::fs::read(tls_cert_path).map_err(|e| {
napi::Error::new(
napi::Status::InvalidArg,
format!("failed to read TLS cert '{}': {}", tls_cert_path, e),
)
})?;
let key_data = std::fs::read(tls_key_path).map_err(|e| {
napi::Error::new(
napi::Status::InvalidArg,
format!("failed to read TLS key '{}': {}", tls_key_path, e),
)
})?;
let certs: Vec<rustls_pki_types::CertificateDer<'static>> =
rustls_pemfile::certs(&mut &cert_data[..])
.collect::<std::result::Result<Vec<_>, std::io::Error>>()
.map_err(|e| {
napi::Error::new(
napi::Status::InvalidArg,
format!("failed to parse TLS certificates: {}", e),
)
})?;
let key: rustls_pki_types::PrivateKeyDer<'static> =
rustls_pemfile::private_key(&mut &key_data[..])
.map_err(|e| {
napi::Error::new(
napi::Status::InvalidArg,
format!("failed to parse TLS private key: {}", e),
)
})?
.ok_or_else(|| {
napi::Error::new(
napi::Status::InvalidArg,
format!("no private key found in {}", tls_key_path),
)
})?;
let acceptor = TlsAcceptor::bind(addr, certs, key, None).await.map_err(|e| {
napi::Error::new(
napi::Status::GenericFailure,
format!("tls bind failed: {}", e),
)
})?;
let actual_listen = acceptor.listen_addr().to_string();
let auth_config = Arc::new(
ServerAuthConfig::from_keys_and_ca(authorized_keys_source, cert_authority_source)
.map_err(|e| {
napi::Error::new(
napi::Status::InvalidArg,
format!("auth config error: {}", e),
)
})?,
);
let private_key =
wraith_core::auth::keys::load_private_key(host_key_source).map_err(|e| {
napi::Error::new(napi::Status::InvalidArg, format!("host key error: {}", e))
})?;
let config = Arc::new(server::Config {
keys: vec![private_key],
..Default::default()
});
let connection_limiter = Arc::new(ConnectionRateLimiter::new(0));
let shutdown_rx = shutdown_tx.subscribe();
let tsfn_holder: Arc<Mutex<Option<ServerTsfn>>> = Arc::new(Mutex::new(None));
let tsfn_for_loop = tsfn_holder.clone();
tokio::spawn(async move {
run_accept_loop(
acceptor,
config,
auth_config,
connection_limiter,
shutdown_rx,
tsfn_for_loop,
"tls".to_string(),
)
.await;
});
Ok(WraithServer {
shutdown_tx,
listen_addr: actual_listen,
endpoint_id: None,
on_connection_tsfn: tsfn_holder,
})
}
ServeTransportMode::Iroh => {
use wraith_core::transport::IrohAcceptor;
let relay_url: Option<iroh::RelayUrl> = match options.iroh_relay.as_deref() {
Some(u) => Some(u.parse().map_err(|e| {
napi::Error::new(
napi::Status::InvalidArg,
format!("invalid iroh relay URL: {}", e),
)
})?),
None => None,
};
let proxy_url: Option<url::Url> = match options.proxy.as_deref() {
Some(u) => Some(u.parse().map_err(|e| {
napi::Error::new(
napi::Status::InvalidArg,
format!("invalid proxy URL: {}", e),
)
})?),
None => None,
};
let acceptor = IrohAcceptor::bind(relay_url, proxy_url)
.await
.map_err(|e| {
napi::Error::new(
napi::Status::GenericFailure,
format!("iroh bind failed: {}", e),
)
})?;
let iroh_endpoint_id = acceptor.endpoint_id();
let auth_config = Arc::new(
ServerAuthConfig::from_keys_and_ca(authorized_keys_source, cert_authority_source)
.map_err(|e| {
napi::Error::new(
napi::Status::InvalidArg,
format!("auth config error: {}", e),
)
})?,
);
let private_key =
wraith_core::auth::keys::load_private_key(host_key_source).map_err(|e| {
napi::Error::new(napi::Status::InvalidArg, format!("host key error: {}", e))
})?;
let config = Arc::new(server::Config {
keys: vec![private_key],
..Default::default()
});
let connection_limiter = Arc::new(ConnectionRateLimiter::new(0));
let shutdown_rx = shutdown_tx.subscribe();
let tsfn_holder: Arc<Mutex<Option<ServerTsfn>>> = Arc::new(Mutex::new(None));
let tsfn_for_loop = tsfn_holder.clone();
tokio::spawn(async move {
run_accept_loop(
acceptor,
config,
auth_config,
connection_limiter,
shutdown_rx,
tsfn_for_loop,
"iroh".to_string(),
)
.await;
});
Ok(WraithServer {
shutdown_tx,
listen_addr: String::new(),
endpoint_id: Some(iroh_endpoint_id),
on_connection_tsfn: tsfn_holder,
})
}
ServeTransportMode::Tls => Err(napi::Error::new(
napi::Status::GenericFailure,
"TLS transport is not yet supported in napi serve()".to_string(),
)),
ServeTransportMode::Iroh => Err(napi::Error::new(
napi::Status::GenericFailure,
"iroh transport is not yet supported in napi serve()".to_string(),
)),
}
}
@@ -457,6 +646,7 @@ async fn run_accept_loop<A>(
connection_limiter: Arc<ConnectionRateLimiter>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
tsfn_holder: Arc<Mutex<Option<ServerTsfn>>>,
transport_kind_str: String,
) where
A: TransportAcceptor + Send + Sync + 'static,
{
@@ -495,7 +685,7 @@ async fn run_accept_loop<A>(
let config = Arc::clone(&config);
let tsfn_holder = tsfn_holder.clone();
let remote_addr_str = remote_addr.map(|a| a.to_string());
let transport_kind_str = "tcp".to_string();
let transport_kind_str = transport_kind_str.clone();
tokio::spawn(async move {
let running = match server::run_stream(config, stream, handler).await {