diff --git a/Cargo.lock b/Cargo.lock index ed69c37..dc6a356 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2395,6 +2395,7 @@ version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1d395473824516f38dd1071a1a37bc57daa7be65b293ebba4ead5f7abb017a2" dependencies = [ + "anyhow", "bitflags 2.11.1", "ctor", "futures", @@ -2402,6 +2403,7 @@ dependencies = [ "napi-sys", "nohash-hasher", "rustc-hash", + "tokio", ] [[package]] @@ -5593,6 +5595,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "futures", "ipnetwork", "iroh", "rand 0.10.1", @@ -5620,6 +5623,8 @@ version = "0.1.0" dependencies = [ "napi", "napi-derive", + "russh", + "tokio", "wraith-core", ] diff --git a/crates/wraith-napi/Cargo.toml b/crates/wraith-napi/Cargo.toml index 3908592..e24ef2e 100644 --- a/crates/wraith-napi/Cargo.toml +++ b/crates/wraith-napi/Cargo.toml @@ -7,6 +7,8 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -wraith-core = { path = "../wraith-core" } -napi = "3" -napi-derive = "3" \ No newline at end of file +wraith-core = { path = "../wraith-core", features = ["tls", "iroh"] } +napi = { version = "3", features = ["async", "error_anyhow"] } +napi-derive = "3" +tokio = { version = "1", features = ["io-util", "sync"] } +russh = "0.49" \ No newline at end of file diff --git a/crates/wraith-napi/src/connect.rs b/crates/wraith-napi/src/connect.rs new file mode 100644 index 0000000..3d7049a --- /dev/null +++ b/crates/wraith-napi/src/connect.rs @@ -0,0 +1,249 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use napi::bindgen_prelude::*; +use napi_derive::napi; +use russh::client; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +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}; + +const DEFAULT_HOST: &str = "wraith-control"; +const DEFAULT_PORT: u32 = 0; + +#[napi(object)] +pub struct WraithConnectOptions { + pub server: Option, + pub peer: Option, + pub transport: String, + pub identity: Option>, + pub tls_server_name: Option, + pub insecure: Option, + pub iroh_relay: Option, + pub proxy: Option, +} + +fn resolve_key_source(identity: &Option>) -> Result { + match identity { + None => Err(Error::new( + Status::InvalidArg, + "identity is required: provide a file path (string) or key data (Buffer)", + )), + Some(Either::A(path)) => Ok(KeySource::File(path.into())), + Some(Either::B(buf)) => Ok(KeySource::Memory(buf.to_vec())), + } +} + +fn parse_addr(addr_str: &str) -> Result { + addr_str.parse().map_err(|e| { + Error::new( + Status::InvalidArg, + format!("invalid server address '{}': {}", addr_str, e), + ) + }) +} + +#[napi] +pub struct WraithStream { + read: Arc>>>, + write: Arc>>>, +} + +#[napi] +impl WraithStream { + #[napi] + pub async fn read(&self, size: u32) -> Result { + let mut buf = vec![0u8; size as usize]; + let mut guard = self.read.lock().await; + let n = guard.read(&mut buf).await.map_err(|e| { + Error::new(Status::GenericFailure, format!("read failed: {}", e)) + })?; + if n == 0 { + return Ok(Vec::::new().into()); + } + buf.truncate(n); + Ok(buf.into()) + } + + #[napi] + pub async fn write(&self, data: Buffer) -> Result<()> { + let mut guard = self.write.lock().await; + guard.write_all(&data).await.map_err(|e| { + Error::new(Status::GenericFailure, format!("write failed: {}", e)) + })?; + Ok(()) + } + + #[napi] + pub async fn close(&self) -> Result<()> { + let mut guard = self.write.lock().await; + guard.shutdown().await.map_err(|e| { + Error::new(Status::GenericFailure, format!("close failed: {}", e)) + }) + } +} + +#[napi] +pub async fn connect(options: WraithConnectOptions) -> Result { + let key_source = resolve_key_source(&options.identity)?; + let auth_config = Arc::new(ClientAuthConfig::from_key_source(key_source).map_err(|e| { + Error::new(Status::InvalidArg, format!("invalid identity key: {}", e)) + })?); + + let transport_mode = options.transport.to_lowercase(); + let handler = ClientHandler::from_config(&auth_config); + let username = "wraith".to_string(); + + let config = Arc::new(client::Config::default()); + + let mut handle: client::Handle = match transport_mode.as_str() { + "tcp" => { + let server = options.server.as_ref().ok_or_else(|| { + Error::new(Status::InvalidArg, "server is required for tcp transport") + })?; + let addr = parse_addr(server)?; + let transport = TcpTransport::new(addr); + let stream = transport.connect().await.map_err(|e| { + Error::new(Status::GenericFailure, format!("tcp connect failed: {}", e)) + })?; + client::connect_stream(config, stream, handler) + .await + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("ssh handshake failed: {}", e), + ) + })? + } + "tls" => { + let server = options.server.as_ref().ok_or_else(|| { + Error::new(Status::InvalidArg, "server is required for tls transport") + })?; + let addr = parse_addr(server)?; + let mut transport = TlsTransport::new(addr); + if let Some(ref name) = options.tls_server_name { + transport = transport.with_server_name(name); + } + if let Some(true) = options.insecure { + transport = transport.with_insecure(true); + } + let stream = transport.connect().await.map_err(|e| { + Error::new(Status::GenericFailure, format!("tls connect failed: {}", e)) + })?; + client::connect_stream(config, stream, handler) + .await + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("ssh handshake failed: {}", e), + ) + })? + } + "iroh" => { + return Err(Error::new( + Status::GenericFailure, + "iroh transport is not yet supported in napi connect()".to_string(), + )); + } + _ => { + return Err(Error::new( + Status::InvalidArg, + format!("unknown transport '{}'; expected tcp, tls, or iroh", transport_mode), + )); + } + }; + + let auth_ok = auth_config + .authenticate(&mut handle, &username) + .await + .map_err(|e| { + Error::new(Status::GenericFailure, format!("ssh auth failed: {}", e)) + })?; + if !auth_ok { + return Err(Error::new(Status::GenericFailure, "ssh authentication rejected")); + } + + let channel = handle + .channel_open_direct_tcpip(DEFAULT_HOST, DEFAULT_PORT, "127.0.0.1", 0) + .await + .map_err(|e| { + Error::new( + Status::GenericFailure, + format!("failed to open ssh channel: {}", e), + ) + })?; + + let stream = channel.into_stream(); + let (read_half, write_half) = tokio::io::split(stream); + + Ok(WraithStream { + read: Arc::new(Mutex::new(read_half)), + write: Arc::new(Mutex::new(write_half)), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ED25519_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n"; + + #[test] + fn resolve_key_source_file_path() { + let identity = Some(Either::::A("/path/to/key".to_string())); + let result = resolve_key_source(&identity); + assert!(result.is_ok()); + match result.unwrap() { + KeySource::File(p) => assert_eq!(p.to_str(), Some("/path/to/key")), + _ => panic!("expected File variant"), + } + } + + #[test] + fn resolve_key_source_buffer() { + let identity = Some(Either::::B(Buffer::from(ED25519_PRIVATE_KEY.as_bytes().to_vec()))); + let result = resolve_key_source(&identity); + assert!(result.is_ok()); + match result.unwrap() { + KeySource::Memory(data) => assert!(!data.is_empty()), + _ => panic!("expected Memory variant"), + } + } + + #[test] + fn resolve_key_source_missing() { + let identity: Option> = None; + let result = resolve_key_source(&identity); + assert!(result.is_err()); + } + + #[test] + fn parse_addr_valid() { + let addr = parse_addr("127.0.0.1:22"); + assert!(addr.is_ok()); + assert_eq!(addr.unwrap().port(), 22); + } + + #[test] + fn parse_addr_invalid() { + let addr = parse_addr("not-an-address"); + assert!(addr.is_err()); + } + + #[test] + fn auth_config_from_memory_key() { + let source = KeySource::Memory(ED25519_PRIVATE_KEY.as_bytes().to_vec()); + let config = ClientAuthConfig::from_key_source(source); + assert!(config.is_ok()); + } + + #[test] + fn auth_config_from_invalid_key() { + let source = KeySource::Memory(b"not-a-key".to_vec()); + let config = ClientAuthConfig::from_key_source(source); + assert!(config.is_err()); + } +} \ No newline at end of file diff --git a/crates/wraith-napi/src/lib.rs b/crates/wraith-napi/src/lib.rs index 5459320..fd2f6e6 100644 --- a/crates/wraith-napi/src/lib.rs +++ b/crates/wraith-napi/src/lib.rs @@ -1,3 +1,5 @@ #[allow(unused_imports)] #[macro_use] -extern crate napi_derive; \ No newline at end of file +extern crate napi_derive; + +mod connect; \ No newline at end of file