feat(napi): add TLS and iroh transport support to serve() and connect()
This commit is contained in:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user