diff --git a/Cargo.lock b/Cargo.lock index 0ae3e33..6b06ae8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/README.md b/README.md index 006064d..04bc420 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/crates/wraith-napi/Cargo.toml b/crates/wraith-napi/Cargo.toml index 4dc6032..f948059 100644 --- a/crates/wraith-napi/Cargo.toml +++ b/crates/wraith-napi/Cargo.toml @@ -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" \ No newline at end of file +async-trait = "0.1" +rustls-pemfile = "2" +rustls-pki-types = "1" +iroh = "0.34" +url = "2" \ No newline at end of file diff --git a/crates/wraith-napi/src/connect.rs b/crates/wraith-napi/src/connect.rs index b061c60..5341c56 100644 --- a/crates/wraith-napi/src/connect.rs +++ b/crates/wraith-napi/src/connect.rs @@ -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 { })? } "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 = 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 = 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( diff --git a/crates/wraith-napi/src/serve.rs b/crates/wraith-napi/src/serve.rs index 125947c..2c944b3 100644 --- a/crates/wraith-napi/src/serve.rs +++ b/crates/wraith-napi/src/serve.rs @@ -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, pub listen: Option, pub iroh_relay: Option, + pub proxy: Option, } fn resolve_key_source( @@ -257,6 +257,7 @@ type ServerTsfn = ThreadsafeFunction, listen_addr: String, + endpoint_id: Option, on_connection_tsfn: Arc>>, } @@ -330,6 +331,11 @@ impl WraithServer { pub fn listen_addr(&self) -> napi::Result { Ok(self.listen_addr.clone()) } + + #[napi(getter, ts_return_type = "string | null")] + pub fn endpoint_id(&self) -> napi::Result> { + Ok(self.endpoint_id.clone()) + } } #[napi] @@ -429,6 +435,7 @@ pub async fn serve(options: WraithServeOptions) -> napi::Result { connection_limiter, shutdown_rx, tsfn_for_loop, + "tcp".to_string(), ) .await; }); @@ -436,17 +443,199 @@ pub async fn serve(options: WraithServeOptions) -> napi::Result { 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_pemfile::certs(&mut &cert_data[..]) + .collect::, 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>> = 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 = 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 = 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>> = 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( connection_limiter: Arc, mut shutdown_rx: tokio::sync::watch::Receiver, tsfn_holder: Arc>>, + transport_kind_str: String, ) where A: TransportAcceptor + Send + Sync + 'static, { @@ -495,7 +685,7 @@ async fn run_accept_loop( 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 {