1 Commits

Author SHA1 Message Date
7dcf7502b7 feat(server): implement stealth mode protocol multiplexing (ADR-017)
Add stealth mode detection that peeks at the first bytes after TLS handshake
to determine SSH vs HTTP protocol. SSH connections proceed to russh handler;
non-SSH connections receive a fake nginx 404 response, making the server
indistinguishable from an ordinary HTTPS site to scanners and DPI systems.

- ProtocolDetection enum (Ssh, Http) for protocol classification
- detect_protocol() uses BufReader::fill_buf() to peek without consuming bytes
- send_fake_nginx_404() writes HTTP/1.1 404 + Server: nginx headers
- validate_stealth_config() enforces TLS transport requirement for stealth
- 17 unit tests covering SSH banner, HTTP, random data, and edge cases
2026-06-02 11:13:15 +00:00
7 changed files with 231 additions and 264 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

@@ -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)]

View File

@@ -1,3 +1,5 @@
pub mod handler; pub mod handler;
pub mod stealth;
pub use handler::{ProxyConfig, ProxyMode, ServerHandler}; pub use handler::{ProxyConfig, ProxyMode, ServerHandler};
pub use stealth::{ProtocolDetection, detect_protocol, send_fake_nginx_404, validate_stealth_config};

View File

@@ -0,0 +1,218 @@
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
const SSH_BANNER_PREFIX: &[u8] = b"SSH-2.0-";
const FAKE_NGINX_404: &[u8] = b"HTTP/1.1 404 Not Found\r\nServer: nginx\r\n\r\n";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolDetection {
Ssh,
Http,
}
pub async fn detect_protocol<S>(stream: S) -> (ProtocolDetection, BufReader<S>)
where
S: AsyncRead + Unpin,
{
let mut reader = BufReader::new(stream);
let detection = match reader.fill_buf().await {
Ok(buf) if buf.len() >= SSH_BANNER_PREFIX.len() => {
if &buf[..SSH_BANNER_PREFIX.len()] == SSH_BANNER_PREFIX {
ProtocolDetection::Ssh
} else {
ProtocolDetection::Http
}
}
Ok(buf) if !buf.is_empty() => {
if buf.starts_with(SSH_BANNER_PREFIX) {
ProtocolDetection::Ssh
} else {
ProtocolDetection::Http
}
}
_ => ProtocolDetection::Http,
};
(detection, reader)
}
pub async fn send_fake_nginx_404<S>(reader: &mut BufReader<S>)
where
S: AsyncRead + AsyncWrite + Unpin,
{
let _ = reader.get_mut().write_all(FAKE_NGINX_404).await;
let _ = reader.get_mut().shutdown().await;
}
pub fn validate_stealth_config(stealth: bool, transport_is_tls: bool) -> Result<(), &'static str> {
if stealth && !transport_is_tls {
return Err("stealth mode requires TLS transport (--transport tls)");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
async fn write_and_detect(data: &[u8]) -> ProtocolDetection {
let (client, server) = duplex(1024);
let mut client = client;
client.write_all(data).await.unwrap();
drop(client);
let (detection, _) = detect_protocol(server).await;
detection
}
#[tokio::test]
async fn ssh_banner_detected() {
let detection = write_and_detect(b"SSH-2.0-OpenSSH_9.0\r\n").await;
assert_eq!(detection, ProtocolDetection::Ssh);
}
#[tokio::test]
async fn ssh_banner_other_implementation() {
let detection = write_and_detect(b"SSH-2.0-russh_0.49\r\n").await;
assert_eq!(detection, ProtocolDetection::Ssh);
}
#[tokio::test]
async fn ssh_banner_minimal() {
let detection = write_and_detect(b"SSH-2.0-X\n").await;
assert_eq!(detection, ProtocolDetection::Ssh);
}
#[tokio::test]
async fn http_get_detected_as_http() {
let detection = write_and_detect(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n").await;
assert_eq!(detection, ProtocolDetection::Http);
}
#[tokio::test]
async fn http_post_detected_as_http() {
let detection = write_and_detect(b"POST /api HTTP/1.1\r\nHost: example.com\r\n\r\n").await;
assert_eq!(detection, ProtocolDetection::Http);
}
#[tokio::test]
async fn random_data_detected_as_http() {
let detection = write_and_detect(b"\x01\x02\x03\x04\x05\x06\x07\x08").await;
assert_eq!(detection, ProtocolDetection::Http);
}
#[tokio::test]
async fn empty_stream_detected_as_http() {
let (client, server) = duplex(1024);
drop(client);
let (detection, _) = detect_protocol(server).await;
assert_eq!(detection, ProtocolDetection::Http);
}
#[tokio::test]
async fn ssh_banner_bytes_preserved_by_bufreader() {
let (client, server) = duplex(1024);
let mut client = client;
let banner = b"SSH-2.0-OpenSSH_9.0\r\n";
client.write_all(banner).await.unwrap();
client.write_all(b"subsequent data").await.unwrap();
drop(client);
let (detection, mut reader) = detect_protocol(server).await;
assert_eq!(detection, ProtocolDetection::Ssh);
let mut all_data = Vec::new();
reader.read_to_end(&mut all_data).await.unwrap();
assert!(all_data.starts_with(banner), "banner bytes must be preserved after detection");
}
#[tokio::test]
async fn fake_nginx_404_response() {
let (client, server) = duplex(1024);
let (mut client_read, mut client_write) = tokio::io::split(client);
client_write.write_all(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n").await.unwrap();
drop(client_write);
let (detection, mut reader) = detect_protocol(server).await;
assert_eq!(detection, ProtocolDetection::Http);
send_fake_nginx_404(&mut reader).await;
let mut buf = [0u8; 256];
let n = client_read.read(&mut buf).await.unwrap();
let response = String::from_utf8_lossy(&buf[..n]);
assert!(response.contains("HTTP/1.1 404 Not Found"));
assert!(response.contains("Server: nginx"));
}
#[tokio::test]
async fn protocol_detection_enum_equality() {
assert_eq!(ProtocolDetection::Ssh, ProtocolDetection::Ssh);
assert_eq!(ProtocolDetection::Http, ProtocolDetection::Http);
assert_ne!(ProtocolDetection::Ssh, ProtocolDetection::Http);
}
#[test]
fn validate_stealth_without_tls_rejected() {
let result = validate_stealth_config(true, false);
assert!(result.is_err());
assert!(result.unwrap_err().contains("TLS transport"));
}
#[test]
fn validate_stealth_with_tls_accepted() {
let result = validate_stealth_config(true, true);
assert!(result.is_ok());
}
#[test]
fn validate_no_stealth_with_tcp_accepted() {
let result = validate_stealth_config(false, false);
assert!(result.is_ok());
}
#[test]
fn validate_no_stealth_with_tls_accepted() {
let result = validate_stealth_config(false, true);
assert!(result.is_ok());
}
#[tokio::test]
async fn short_data_detected_as_http() {
let detection = write_and_detect(b"GE").await;
assert_eq!(detection, ProtocolDetection::Http);
}
#[tokio::test]
async fn partial_ssh_prefix_detected_as_http() {
let detection = write_and_detect(b"SSH-1.").await;
assert_eq!(detection, ProtocolDetection::Http);
}
#[tokio::test]
async fn http_request_gets_404_then_closed() {
let (client, server) = duplex(1024);
let mut client = client;
client.write_all(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n").await.unwrap();
let (detection, mut reader) = detect_protocol(server).await;
assert_eq!(detection, ProtocolDetection::Http);
send_fake_nginx_404(&mut reader).await;
let mut buf = [0u8; 256];
let n = client.read(&mut buf).await.unwrap();
let response = String::from_utf8_lossy(&buf[..n]);
assert!(response.starts_with("HTTP/1.1 404 Not Found"));
assert!(response.contains("Server: nginx"));
let mut extra = [0u8; 16];
let result = client.read(&mut extra).await;
assert!(result.is_err() || result.unwrap() == 0);
}
}

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;