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" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"iroh",
"napi", "napi",
"napi-derive", "napi-derive",
"russh", "russh",
"rustls-pemfile",
"rustls-pki-types",
"tokio", "tokio",
"url",
"wraith-core", "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 ## 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: 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" napi-derive = "3"
tokio = { version = "1", features = ["io-util", "sync", "rt", "macros", "net", "time", "signal"] } tokio = { version = "1", features = ["io-util", "sync", "rt", "macros", "net", "time", "signal"] }
russh = "0.49" 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::client_auth::{ClientAuthConfig, ClientHandler};
use wraith_core::auth::keys::KeySource; 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_HOST: &str = "wraith-control";
const DEFAULT_PORT: u32 = 0; const DEFAULT_PORT: u32 = 0;
@@ -153,10 +153,49 @@ pub async fn connect(options: WraithConnectOptions) -> Result<WraithStream> {
})? })?
} }
"iroh" => { "iroh" => {
return Err(Error::new( let peer = options.peer.as_ref().ok_or_else(|| {
Status::GenericFailure, Error::new(Status::InvalidArg, "peer is required for iroh transport")
"iroh transport is not yet supported in napi connect()".to_string(), })?;
)); 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( return Err(Error::new(

View File

@@ -1,8 +1,7 @@
//! NAPI `serve()` function and `WraithServer` type. //! NAPI `serve()` function and `WraithServer` type.
//! //!
//! Starts a TCP-based SSH server that emits new channel streams via a //! Starts an SSH server that emits new channel streams via a
//! `ThreadsafeFunction` callback. Currently supports TCP transport only; //! `ThreadsafeFunction` callback. Supports TCP, TLS, and iroh transports.
//! TLS and iroh will be added in a future release.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
@@ -32,6 +31,7 @@ pub struct WraithServeOptions {
pub acme_domain: Option<String>, pub acme_domain: Option<String>,
pub listen: Option<String>, pub listen: Option<String>,
pub iroh_relay: Option<String>, pub iroh_relay: Option<String>,
pub proxy: Option<String>,
} }
fn resolve_key_source( fn resolve_key_source(
@@ -257,6 +257,7 @@ type ServerTsfn = ThreadsafeFunction<ConnectionEventWrapper, (), ConnectionEvent
pub struct WraithServer { pub struct WraithServer {
shutdown_tx: tokio::sync::watch::Sender<bool>, shutdown_tx: tokio::sync::watch::Sender<bool>,
listen_addr: String, listen_addr: String,
endpoint_id: Option<String>,
on_connection_tsfn: Arc<Mutex<Option<ServerTsfn>>>, on_connection_tsfn: Arc<Mutex<Option<ServerTsfn>>>,
} }
@@ -330,6 +331,11 @@ impl WraithServer {
pub fn listen_addr(&self) -> napi::Result<String> { pub fn listen_addr(&self) -> napi::Result<String> {
Ok(self.listen_addr.clone()) 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] #[napi]
@@ -429,6 +435,7 @@ pub async fn serve(options: WraithServeOptions) -> napi::Result<WraithServer> {
connection_limiter, connection_limiter,
shutdown_rx, shutdown_rx,
tsfn_for_loop, tsfn_for_loop,
"tcp".to_string(),
) )
.await; .await;
}); });
@@ -436,17 +443,199 @@ pub async fn serve(options: WraithServeOptions) -> napi::Result<WraithServer> {
Ok(WraithServer { Ok(WraithServer {
shutdown_tx, shutdown_tx,
listen_addr: actual_listen, 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, 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>, connection_limiter: Arc<ConnectionRateLimiter>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>, mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
tsfn_holder: Arc<Mutex<Option<ServerTsfn>>>, tsfn_holder: Arc<Mutex<Option<ServerTsfn>>>,
transport_kind_str: String,
) where ) where
A: TransportAcceptor + Send + Sync + 'static, A: TransportAcceptor + Send + Sync + 'static,
{ {
@@ -495,7 +685,7 @@ async fn run_accept_loop<A>(
let config = Arc::clone(&config); let config = Arc::clone(&config);
let tsfn_holder = tsfn_holder.clone(); let tsfn_holder = tsfn_holder.clone();
let remote_addr_str = remote_addr.map(|a| a.to_string()); 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 { tokio::spawn(async move {
let running = match server::run_stream(config, stream, handler).await { let running = match server::run_stream(config, stream, handler).await {