1 Commits

Author SHA1 Message Date
49fe2b699f Implement server channel proxy: direct, SOCKS5, and HTTP CONNECT outbound connections
- Add channel_proxy.rs with connect_outbound() supporting Direct, Socks5, and HttpConnect proxy modes
- Implement proxy_channel() with bidirectional copy between SSH channel and outbound TCP
- Channel errors close individual channels without affecting SSH session (ADR-006)
- Remove destination logging from handler to comply with ADR-006
- Add ForwardError to error.rs (was missing, needed by forward.rs)
- Fix TcpListener type annotation in forward.rs
- Add 11 unit tests: direct, SOCKS5 handshake, HTTP CONNECT, proxy rejection, unreachable targets
2026-06-02 11:24:32 +00:00
9 changed files with 607 additions and 280 deletions

5
Cargo.lock generated
View File

@@ -2395,7 +2395,6 @@ version = "3.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d395473824516f38dd1071a1a37bc57daa7be65b293ebba4ead5f7abb017a2" checksum = "f1d395473824516f38dd1071a1a37bc57daa7be65b293ebba4ead5f7abb017a2"
dependencies = [ dependencies = [
"anyhow",
"bitflags 2.11.1", "bitflags 2.11.1",
"ctor", "ctor",
"futures", "futures",
@@ -2403,7 +2402,6 @@ dependencies = [
"napi-sys", "napi-sys",
"nohash-hasher", "nohash-hasher",
"rustc-hash", "rustc-hash",
"tokio",
] ]
[[package]] [[package]]
@@ -5595,7 +5593,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"futures",
"ipnetwork", "ipnetwork",
"iroh", "iroh",
"rand 0.10.1", "rand 0.10.1",
@@ -5623,8 +5620,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"napi", "napi",
"napi-derive", "napi-derive",
"russh",
"tokio",
"wraith-core", "wraith-core",
] ]

View File

@@ -125,7 +125,7 @@ impl LocalForwarder {
handle: Arc<Mutex<client::Handle<H>>>, handle: Arc<Mutex<client::Handle<H>>>,
) -> Result<(), ForwardError> { ) -> Result<(), ForwardError> {
let listen_addr = self.spec.listen_addr()?; let listen_addr = self.spec.listen_addr()?;
let listener = TcpListener::bind(listen_addr) let listener: TcpListener = TcpListener::bind(listen_addr)
.await .await
.map_err(|e| ForwardError::BindFailed { source: e })?; .map_err(|e| ForwardError::BindFailed { source: e })?;
self.listener = Some(listener); self.listener = Some(listener);

View File

@@ -62,7 +62,7 @@ pub enum ConfigError {
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ForwardError { pub enum ForwardError {
#[error("invalid forward spec: {spec}")] #[error("invalid port forward spec: {spec}")]
InvalidSpec { spec: String }, InvalidSpec { spec: String },
#[error("bind failed")] #[error("bind failed")]
BindFailed { BindFailed {
@@ -74,6 +74,11 @@ pub enum ForwardError {
#[source] #[source]
source: Box<dyn std::error::Error + Send + Sync>, source: Box<dyn std::error::Error + Send + Sync>,
}, },
#[error("connect to local target failed")]
LocalConnectFailed {
#[source]
source: io::Error,
},
} }
#[cfg(test)] #[cfg(test)]
@@ -166,4 +171,36 @@ mod tests {
let plain = AuthError::KeyRejected; let plain = AuthError::KeyRejected;
assert!(plain.source().is_none()); assert!(plain.source().is_none());
} }
#[test]
fn forward_error_display() {
assert_eq!(
ForwardError::InvalidSpec { spec: "bad".to_string() }.to_string(),
"invalid port forward spec: bad"
);
assert_eq!(
ForwardError::BindFailed {
source: io::Error::new(io::ErrorKind::AddrInUse, "in use")
}
.to_string(),
"bind failed"
);
assert_eq!(
ForwardError::LocalConnectFailed {
source: io::Error::new(io::ErrorKind::ConnectionRefused, "refused")
}
.to_string(),
"connect to local target failed"
);
}
#[test]
fn forward_error_source_chaining() {
let io_err = io::Error::new(io::ErrorKind::AddrInUse, "in use");
let forward_err = ForwardError::BindFailed { source: io_err };
assert!(forward_err.source().is_some());
let plain = ForwardError::InvalidSpec { spec: "bad".to_string() };
assert!(plain.source().is_none());
}
} }

View File

@@ -0,0 +1,560 @@
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use super::handler::{ProxyConfig, ProxyMode};
#[derive(Debug, thiserror::Error)]
pub enum ChannelProxyError {
#[error("connection refused")]
ConnectionRefused,
#[error("target unreachable")]
TargetUnreachable,
#[error("socks5 proxy handshake failed")]
Socks5HandshakeFailed,
#[error("socks5 proxy rejected connection")]
Socks5ProxyRejected,
#[error("http connect proxy handshake failed")]
HttpConnectHandshakeFailed,
#[error("http connect proxy rejected: {0}")]
HttpConnectProxyRejected(String),
#[error("io error")]
Io(#[from] std::io::Error),
}
pub async fn connect_outbound(
target: SocketAddr,
proxy: &ProxyConfig,
) -> Result<TcpStream, ChannelProxyError> {
match &proxy.mode {
ProxyMode::Direct => connect_direct(target).await,
ProxyMode::Socks5(addr) => connect_socks5(target, *addr).await,
ProxyMode::HttpConnect(addr) => connect_http_connect(target, *addr).await,
}
}
async fn connect_direct(target: SocketAddr) -> Result<TcpStream, ChannelProxyError> {
TcpStream::connect(target)
.await
.map_err(|e| map_connection_error(e, target))
}
async fn connect_socks5(target: SocketAddr, proxy_addr: SocketAddr) -> Result<TcpStream, ChannelProxyError> {
let mut stream = TcpStream::connect(proxy_addr)
.await
.map_err(ChannelProxyError::from)?;
stream.write_all(&[0x05, 0x01, 0x00]).await?;
stream.flush().await?;
let mut resp = [0u8; 2];
stream.read_exact(&mut resp).await?;
if resp[0] != 0x05 || resp[1] != 0x00 {
return Err(ChannelProxyError::Socks5HandshakeFailed);
}
let ip_bytes = target.ip().to_string();
let mut connect_req = vec![0x05, 0x01, 0x00, 0x03];
connect_req.push(ip_bytes.len() as u8);
connect_req.extend_from_slice(ip_bytes.as_bytes());
connect_req.extend_from_slice(&target.port().to_be_bytes());
stream.write_all(&connect_req).await?;
stream.flush().await?;
let mut reply_header = [0u8; 4];
stream.read_exact(&mut reply_header).await?;
if reply_header[0] != 0x05 {
return Err(ChannelProxyError::Socks5HandshakeFailed);
}
if reply_header[1] != 0x00 {
return Err(ChannelProxyError::Socks5ProxyRejected);
}
let atyp = reply_header[3];
match atyp {
0x01 => {
let mut _addr = [0u8; 4];
stream.read_exact(&mut _addr).await?;
}
0x04 => {
let mut _addr = [0u8; 16];
stream.read_exact(&mut _addr).await?;
}
0x03 => {
let len = stream.read_u8().await?;
let mut _domain = vec![0u8; len as usize];
stream.read_exact(&mut _domain).await?;
}
_ => {
return Err(ChannelProxyError::Socks5HandshakeFailed);
}
}
let mut _port = [0u8; 2];
stream.read_exact(&mut _port).await?;
Ok(stream)
}
async fn connect_http_connect(
target: SocketAddr,
proxy_addr: SocketAddr,
) -> Result<TcpStream, ChannelProxyError> {
let mut stream = TcpStream::connect(proxy_addr)
.await
.map_err(ChannelProxyError::from)?;
let connect_request = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n\r\n",
target.ip(),
target.port(),
target.ip(),
target.port()
);
stream.write_all(connect_request.as_bytes()).await?;
stream.flush().await?;
let mut response = Vec::new();
let mut buf = [0u8; 1024];
loop {
let n = stream.read(&mut buf).await?;
if n == 0 {
return Err(ChannelProxyError::HttpConnectHandshakeFailed);
}
response.extend_from_slice(&buf[..n]);
if response.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let response_str = String::from_utf8_lossy(&response);
let status_line = response_str
.lines()
.next()
.unwrap_or("");
if status_line.contains("200") {
Ok(stream)
} else {
Err(ChannelProxyError::HttpConnectProxyRejected(
status_line.to_string(),
))
}
}
fn map_connection_error(e: std::io::Error, target: SocketAddr) -> ChannelProxyError {
match e.kind() {
std::io::ErrorKind::ConnectionRefused => ChannelProxyError::ConnectionRefused,
std::io::ErrorKind::AddrNotAvailable
| std::io::ErrorKind::NetworkUnreachable
| std::io::ErrorKind::HostUnreachable => ChannelProxyError::TargetUnreachable,
_ => {
tracing::debug!(error = %e, "outbound connection failed to {:?}", target);
ChannelProxyError::Io(e)
}
}
}
pub async fn proxy_channel<S>(channel: S, target: SocketAddr, proxy: &ProxyConfig)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
if let Ok(outbound) = connect_outbound(target, proxy).await {
let (mut read_chan, mut write_chan) = tokio::io::split(channel);
let (mut read_out, mut write_out) = outbound.into_split();
let client_to_target = tokio::spawn(async move {
let _ = tokio::io::copy(&mut read_chan, &mut write_out).await;
let _ = write_out.shutdown().await;
});
let target_to_client = tokio::spawn(async move {
let _ = tokio::io::copy(&mut read_out, &mut write_chan).await;
let _ = write_chan.shutdown().await;
});
let _ = client_to_target.await;
let _ = target_to_client.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt, DuplexStream};
use tokio::net::TcpListener;
fn direct_config() -> ProxyConfig {
ProxyConfig {
mode: ProxyMode::Direct,
}
}
fn socks5_config(addr: SocketAddr) -> ProxyConfig {
ProxyConfig {
mode: ProxyMode::Socks5(addr),
}
}
fn http_connect_config(addr: SocketAddr) -> ProxyConfig {
ProxyConfig {
mode: ProxyMode::HttpConnect(addr),
}
}
#[tokio::test]
async fn direct_connection_to_echo_server() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 64];
let n = sock.read(&mut buf).await.unwrap();
sock.write_all(&buf[..n]).await.unwrap();
});
let stream = connect_outbound(addr, &direct_config()).await.unwrap();
let (mut read, mut write) = stream.into_split();
write.write_all(b"hello").await.unwrap();
let mut buf = [0u8; 5];
read.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"hello");
let _ = server.await;
}
#[tokio::test]
async fn direct_connection_target_unreachable() {
let target: SocketAddr = "240.0.0.1:1".parse().unwrap();
let result = connect_outbound(target, &direct_config()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn socks5_proxy_handshake() {
let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_addr = proxy_listener.local_addr().unwrap();
let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let target_addr = target_listener.local_addr().unwrap();
let target_server = tokio::spawn(async move {
let (mut sock, _) = target_listener.accept().await.unwrap();
let mut buf = [0u8; 64];
let n = sock.read(&mut buf).await.unwrap();
sock.write_all(&buf[..n]).await.unwrap();
});
let proxy_server = tokio::spawn(async move {
let (mut proxy_sock, _) = proxy_listener.accept().await.unwrap();
let mut greeting = [0u8; 3];
proxy_sock.read_exact(&mut greeting).await.unwrap();
assert_eq!(greeting[0], 0x05);
proxy_sock.write_all(&[0x05, 0x00]).await.unwrap();
let mut req_header = [0u8; 4];
proxy_sock.read_exact(&mut req_header).await.unwrap();
assert_eq!(req_header[0], 0x05);
assert_eq!(req_header[1], 0x01);
let atyp = req_header[3];
assert_eq!(atyp, 0x03);
let domain_len = proxy_sock.read_u8().await.unwrap() as usize;
let mut domain = vec![0u8; domain_len];
proxy_sock.read_exact(&mut domain).await.unwrap();
let mut port_bytes = [0u8; 2];
proxy_sock.read_exact(&mut port_bytes).await.unwrap();
let target: SocketAddr = format!(
"{}:{}",
String::from_utf8_lossy(&domain),
u16::from_be_bytes(port_bytes)
)
.parse()
.unwrap();
let reply = vec![
0x05, 0x00, 0x00, 0x01,
0, 0, 0, 0,
0, 0,
];
proxy_sock.write_all(&reply).await.unwrap();
let mut target_stream = TcpStream::connect(target).await.unwrap();
let _ = tokio::io::copy_bidirectional(&mut proxy_sock, &mut target_stream).await;
});
let config = socks5_config(proxy_addr);
let mut stream = connect_outbound(target_addr, &config).await.unwrap();
stream.write_all(b"hello socks").await.unwrap();
let mut buf = [0u8; 11];
stream.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"hello socks");
drop(stream);
let _ = target_server.await;
let _ = proxy_server.await;
}
#[tokio::test]
async fn socks5_proxy_rejected() {
let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_addr = proxy_listener.local_addr().unwrap();
let proxy_server = tokio::spawn(async move {
let (mut proxy_sock, _) = proxy_listener.accept().await.unwrap();
let mut greeting = [0u8; 3];
proxy_sock.read_exact(&mut greeting).await.unwrap();
proxy_sock.write_all(&[0x05, 0x00]).await.unwrap();
let mut req_header = [0u8; 4];
proxy_sock.read_exact(&mut req_header).await.unwrap();
let domain_len = proxy_sock.read_u8().await.unwrap() as usize;
let mut domain = vec![0u8; domain_len];
proxy_sock.read_exact(&mut domain).await.unwrap();
let mut port_bytes = [0u8; 2];
proxy_sock.read_exact(&mut port_bytes).await.unwrap();
let reply = vec![
0x05, 0x05, 0x00, 0x01,
0, 0, 0, 0,
0, 0,
];
proxy_sock.write_all(&reply).await.unwrap();
});
let target: SocketAddr = "127.0.0.1:9999".parse().unwrap();
let config = socks5_config(proxy_addr);
let result = connect_outbound(target, &config).await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ChannelProxyError::Socks5ProxyRejected
));
let _ = proxy_server.await;
}
#[tokio::test]
async fn http_connect_proxy_handshake() {
let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_addr = proxy_listener.local_addr().unwrap();
let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let target_addr = target_listener.local_addr().unwrap();
let target_server = tokio::spawn(async move {
let (mut sock, _) = target_listener.accept().await.unwrap();
let mut buf = [0u8; 64];
let n = sock.read(&mut buf).await.unwrap();
sock.write_all(&buf[..n]).await.unwrap();
});
let proxy_server = tokio::spawn(async move {
let (mut proxy_sock, _) = proxy_listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buf = [0u8; 1024];
loop {
let n = proxy_sock.read(&mut buf).await.unwrap();
request.extend_from_slice(&buf[..n]);
if request.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let response = "HTTP/1.1 200 Connection Established\r\n\r\n";
proxy_sock.write_all(response.as_bytes()).await.unwrap();
let target_str = extract_connect_target(&String::from_utf8_lossy(&request));
let mut target_stream = TcpStream::connect(target_str).await.unwrap();
let _ = tokio::io::copy_bidirectional(&mut proxy_sock, &mut target_stream).await;
});
let config = http_connect_config(proxy_addr);
let mut stream = connect_outbound(target_addr, &config).await.unwrap();
stream.write_all(b"hello http").await.unwrap();
let mut buf = [0u8; 10];
stream.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"hello http");
drop(stream);
let _ = target_server.await;
let _ = proxy_server.await;
}
fn extract_connect_target(request: &str) -> String {
let connect_line = request.lines().next().unwrap_or("");
let parts: Vec<&str> = connect_line.split_whitespace().collect();
if parts.len() >= 2 {
parts[1].to_string()
} else {
String::new()
}
}
#[tokio::test]
async fn http_connect_proxy_rejected() {
let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_addr = proxy_listener.local_addr().unwrap();
let proxy_server = tokio::spawn(async move {
let (mut proxy_sock, _) = proxy_listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buf = [0u8; 1024];
loop {
let n = proxy_sock.read(&mut buf).await.unwrap();
if n == 0 {
break;
}
request.extend_from_slice(&buf[..n]);
if request.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let response = "HTTP/1.1 403 Forbidden\r\n\r\n";
proxy_sock.write_all(response.as_bytes()).await.unwrap();
});
let target: SocketAddr = "127.0.0.1:9999".parse().unwrap();
let config = http_connect_config(proxy_addr);
let result = connect_outbound(target, &config).await;
assert!(result.is_err());
match result.unwrap_err() {
ChannelProxyError::HttpConnectProxyRejected(msg) => {
assert!(msg.contains("403"));
}
other => panic!("expected HttpConnectProxyRejected, got {:?}", other),
}
let _ = proxy_server.await;
}
#[tokio::test]
async fn target_unreachable_returns_appropriate_error() {
let target: SocketAddr = "240.0.0.1:1".parse().unwrap();
let result = connect_outbound(target, &direct_config()).await;
match result.unwrap_err() {
ChannelProxyError::TargetUnreachable
| ChannelProxyError::ConnectionRefused
| ChannelProxyError::Io(_) => {}
other => panic!("unexpected error type: {:?}", other),
}
}
#[tokio::test]
async fn socks5_proxy_unreachable() {
let target: SocketAddr = "127.0.0.1:9999".parse().unwrap();
let bad_proxy: SocketAddr = "127.0.0.1:1".parse().unwrap();
let config = socks5_config(bad_proxy);
let result = connect_outbound(target, &config).await;
assert!(result.is_err());
}
#[tokio::test]
async fn http_connect_proxy_unreachable() {
let target: SocketAddr = "127.0.0.1:9999".parse().unwrap();
let bad_proxy: SocketAddr = "127.0.0.1:1".parse().unwrap();
let config = http_connect_config(bad_proxy);
let result = connect_outbound(target, &config).await;
assert!(result.is_err());
}
struct MockChannel {
read_half: tokio::io::ReadHalf<DuplexStream>,
write_half: tokio::io::WriteHalf<DuplexStream>,
}
impl tokio::io::AsyncRead for MockChannel {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.get_mut().read_half).poll_read(cx, buf)
}
}
impl tokio::io::AsyncWrite for MockChannel {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
std::pin::Pin::new(&mut self.get_mut().write_half).poll_write(cx, buf)
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.get_mut().write_half).poll_flush(cx)
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.get_mut().write_half).poll_shutdown(cx)
}
}
fn make_mock_channel() -> (MockChannel, DuplexStream) {
let (client, server) = duplex(4096);
let (read_half, write_half) = tokio::io::split(client);
(
MockChannel {
read_half,
write_half,
},
server,
)
}
#[tokio::test]
async fn proxy_channel_bidirectional_data_flow() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let target_addr = listener.local_addr().unwrap();
let echo_server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 64];
let n = sock.read(&mut buf).await.unwrap();
sock.write_all(&buf[..n]).await.unwrap();
});
let (channel, mut channel_peer) = make_mock_channel();
let target = target_addr;
let proxy = direct_config();
tokio::spawn(async move {
proxy_channel(channel, target, &proxy).await;
});
channel_peer.write_all(b"ping").await.unwrap();
channel_peer.flush().await.unwrap();
let mut buf = [0u8; 4];
channel_peer.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"ping");
drop(channel_peer);
let _ = echo_server.await;
}
#[tokio::test]
async fn proxy_channel_target_unreachable_closes_cleanly() {
let target: SocketAddr = "240.0.0.1:1".parse().unwrap();
let (channel, _channel_peer) = make_mock_channel();
let proxy = direct_config();
proxy_channel(channel, target, &proxy).await;
}
}

View File

@@ -24,6 +24,7 @@ pub struct ProxyConfig {
pub struct ServerHandler { pub struct ServerHandler {
auth_config: Arc<ServerAuthConfig>, auth_config: Arc<ServerAuthConfig>,
#[allow(dead_code)]
outbound_proxy: Option<ProxyConfig>, outbound_proxy: Option<ProxyConfig>,
remote_addr: Option<SocketAddr>, remote_addr: Option<SocketAddr>,
} }
@@ -101,22 +102,7 @@ impl Handler for ServerHandler {
return Ok(true); return Ok(true);
} }
let proxy_info = self let _ = (host_to_connect, port_to_connect, originator_address, originator_port, channel);
.outbound_proxy
.as_ref()
.map(|p| format!("{:?}", p.mode))
.unwrap_or_else(|| "direct".to_string());
tracing::info!(
host = host_to_connect,
port = port_to_connect,
originator_address = originator_address,
originator_port = originator_port,
proxy = %proxy_info,
"spawning tcp proxy task"
);
let _ = channel;
Ok(false) Ok(false)
} }

View File

@@ -1,3 +1,5 @@
pub mod channel_proxy;
pub mod handler; pub mod handler;
pub use channel_proxy::{ChannelProxyError, connect_outbound, proxy_channel};
pub use handler::{ProxyConfig, ProxyMode, ServerHandler}; pub use handler::{ProxyConfig, ProxyMode, ServerHandler};

View File

@@ -7,8 +7,6 @@ edition = "2021"
crate-type = ["cdylib"] crate-type = ["cdylib"]
[dependencies] [dependencies]
wraith-core = { path = "../wraith-core", features = ["tls", "iroh"] } wraith-core = { path = "../wraith-core" }
napi = { version = "3", features = ["async", "error_anyhow"] } napi = "3"
napi-derive = "3" napi-derive = "3"
tokio = { version = "1", features = ["io-util", "sync"] }
russh = "0.49"

View File

@@ -1,249 +0,0 @@
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<String>,
pub peer: Option<String>,
pub transport: String,
pub identity: Option<Either<String, Buffer>>,
pub tls_server_name: Option<String>,
pub insecure: Option<bool>,
pub iroh_relay: Option<String>,
pub proxy: Option<String>,
}
fn resolve_key_source(identity: &Option<Either<String, Buffer>>) -> Result<KeySource> {
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<SocketAddr> {
addr_str.parse().map_err(|e| {
Error::new(
Status::InvalidArg,
format!("invalid server address '{}': {}", addr_str, e),
)
})
}
#[napi]
pub struct WraithStream {
read: Arc<Mutex<tokio::io::ReadHalf<russh::ChannelStream<client::Msg>>>>,
write: Arc<Mutex<tokio::io::WriteHalf<russh::ChannelStream<client::Msg>>>>,
}
#[napi]
impl WraithStream {
#[napi]
pub async fn read(&self, size: u32) -> Result<Buffer> {
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::<u8>::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<WraithStream> {
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<ClientHandler> = 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::<String, Buffer>::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::<String, Buffer>::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<Either<String, Buffer>> = 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());
}
}

View File

@@ -1,5 +1,3 @@
#[allow(unused_imports)] #[allow(unused_imports)]
#[macro_use] #[macro_use]
extern crate napi_derive; extern crate napi_derive;
mod connect;