diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec6ad1..3af1131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,14 @@ ### Added +- Add experimental UDP support via `udp.sandhole` alias. +- Add `tcp.sandhole` alias for TCP proxying. - Add `--completions` CLI flag. ### Changed - **BREAKING**: Use gauges for `system_used_memory` and `system_total_memory`. +- **BREAKING**: Reserve `.sandhole` aliases for Sandhole. - Update dependencies. ## 0.9.5 (2026-05-25) diff --git a/src/entrypoint.rs b/src/entrypoint.rs index d28cbc7..6aa55be 100644 --- a/src/entrypoint.rs +++ b/src/entrypoint.rs @@ -84,9 +84,9 @@ pub async fn entrypoint(config: ApplicationConfig) -> color_eyre::Result<()> { #[cfg(not(coverage_nightly))] tracing::info!("Starting Sandhole..."); // Check configuration flags for issues or other operations - if config.disable_http && config.disable_tcp && config.disable_aliasing { + if config.disable_http && config.disable_tcp && config.disable_udp && config.disable_aliasing { return Err(ServerError::InvalidConfig( - "One of HTTP, TCP, or aliasing must be enabled".into(), + "One of HTTP, TCP, UDP, or aliasing must be enabled".into(), ) .into()); } diff --git a/src/ssh/forwarding.rs b/src/ssh/forwarding.rs index 3f21c7a..367b8a5 100644 --- a/src/ssh/forwarding.rs +++ b/src/ssh/forwarding.rs @@ -91,6 +91,7 @@ pub(crate) trait ForwardingHandlerStrategy { pub(crate) struct Forwarder; +pub(crate) const TCP_ADDRESS: &str = "tcp.sandhole"; pub(crate) const UDP_ADDRESS: &str = "udp.sandhole"; impl Forwarder { @@ -119,12 +120,12 @@ impl Forwarder { UdpForwardingHandler .remote_forwarding(context, address, port, handle) .await - } else if context.server.is_alias(address) { - AliasForwardingHandler + } else if address == TCP_ADDRESS || !context.server.is_alias(address) { + TcpForwardingHandler .remote_forwarding(context, address, port, handle) .await } else { - TcpForwardingHandler + AliasForwardingHandler .remote_forwarding(context, address, port, handle) .await } @@ -158,13 +159,13 @@ impl Forwarder { .cancel_remote_forwarding(context, address, port) .await } - _ if context.server.is_alias(address) => { - AliasForwardingHandler + _ if address == TCP_ADDRESS || !context.server.is_alias(address) => { + TcpForwardingHandler .cancel_remote_forwarding(context, address, port) .await } _ => { - TcpForwardingHandler + AliasForwardingHandler .cancel_remote_forwarding(context, address, port) .await } @@ -216,8 +217,8 @@ impl Forwarder { channel, ) .await - } else if context.server.is_alias(address) { - AliasForwardingHandler + } else if address == TCP_ADDRESS || !context.server.is_alias(address) { + TcpForwardingHandler .local_forwarding( context, address, @@ -228,7 +229,7 @@ impl Forwarder { ) .await } else { - TcpForwardingHandler + AliasForwardingHandler .local_forwarding( context, address, @@ -1162,6 +1163,27 @@ impl ForwardingHandlerStrategy for AliasForwardingHandler { .into_bytes(), ); return Ok(false); + } else if address.ends_with(".sandhole") { + // Port 10 is reserved for admin aliases + let error = eyre!("TLD .sandhole is reserved by Sandhole"); + #[cfg(not(coverage_nightly))] + tracing::debug!( + peer = %context.peer, alias = %address, %error, + "Failed to bind address for alias.", + ); + let _ = context.tx.send( + format!( + "{} {} Cannot listen on address {}:{} ({})\r\n{}\r\n", + Utc::now().to_rfc3339().dimmed(), + " Error ".black().on_red().bold(), + address, + port, + error, + " = hint: specify a different address".dimmed(), + ) + .into_bytes(), + ); + return Ok(false); } else { // Allow user-requested port *port as u16 diff --git a/src/udp.rs b/src/udp.rs index 64f046b..8723300 100644 --- a/src/udp.rs +++ b/src/udp.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, net::IpAddr, sync::Arc}; +use std::{collections::HashSet, mem::size_of, net::IpAddr, sync::Arc}; use crate::{ connection_handler::ConnectionHandler, @@ -21,13 +21,13 @@ use tokio::{ select, }; -pub const MAX_PACKET_SIZE: usize = std::mem::size_of::() + u16::MAX as usize; +pub const MAX_PACKET_SIZE: usize = size_of::() + u16::MAX as usize; -/// Creates and returns a buffer on the heap with enough space to contain any possible -/// UDP datagram. -/// -/// This is put on the heap and in a separate function to avoid the 64k buffer from ending -/// up on the stack and blowing up the size of the futures using it. +// Creates and returns a buffer on the heap with enough space to contain any possible +// UDP datagram. +// +// This is put on the heap and in a separate function to avoid the 64k buffer from ending +// up on the stack and blowing up the size of the futures using it. #[inline] fn datagram_buffer() -> Box<[u8; MAX_PACKET_SIZE]> { Box::new([0u8; MAX_PACKET_SIZE]) @@ -74,8 +74,8 @@ impl UdpPortHandler for Arc { let mut socket = socket; loop { let mut buf = datagram_buffer(); - match socket.peek_from(buf.as_mut()).await { - Ok((_, address)) => { + match socket.recv_from(buf.as_mut()).await { + Ok((len, address)) => { let ip = address.ip().to_canonical(); if !clone.ip_filter.is_allowed(ip) { #[cfg(not(coverage_nightly))] @@ -100,34 +100,51 @@ impl UdpPortHandler for Arc { .into_bytes(), ); } + let socket = std::mem::replace( + &mut socket, + get_udp_socket((listen_address, port)) + .expect("should re-create UDP socket"), + ); if let Err(error) = socket.connect(address).await { #[cfg(not(coverage_nightly))] tracing::error!(%port, %error, "Error connecting UDP socket."); continue; } - let connected_socket = socket; - socket = get_udp_socket((listen_address, port)).expect(""); + let mut read_buf = datagram_buffer(); + + // Prepare already consumed data to be sent to SSH channel + *read_buf[..size_of::()] + .as_mut_array() + .expect("length checked") = (len as u16).to_be_bytes(); + read_buf[size_of::()..size_of::() + len] + .copy_from_slice(&buf[..len]); + tokio::spawn(async move { - let udp_read = Arc::new(connected_socket); + let udp_read = Arc::new(socket); let udp_write = udp_read.clone(); let (mut ssh_read, mut ssh_write) = tokio::io::split(channel); let udp2ssh = async move { - let mut buf = datagram_buffer(); + let mut buf = read_buf; + if let Err(error) = + ssh_write.write_all(&buf[..size_of::() + len]).await + { + #[cfg(not(coverage_nightly))] + tracing::warn!(%port, %error, "Error writing to SSH channel for UDP."); + return; + } loop { match udp_read - .recv(&mut buf.as_mut()[std::mem::size_of::()..]) + .recv(&mut buf.as_mut()[size_of::()..]) .await { Ok(len) => { - *buf[..std::mem::size_of::()] + *buf[..size_of::()] .as_mut_array() .expect("length checked") = (len as u16).to_be_bytes(); if let Err(error) = ssh_write - .write_all( - &buf[..std::mem::size_of::() + len], - ) + .write_all(&buf[..size_of::() + len]) .await { #[cfg(not(coverage_nightly))] @@ -184,7 +201,7 @@ impl UdpPortHandler for Arc { } Err(error) => { #[cfg(not(coverage_nightly))] - tracing::warn!(%port, %error, "Error reading from UDP port.") + tracing::warn!(%port, %error, "Error getting remote connection on UDP port.") } } } diff --git a/src/udp_listener.rs b/src/udp_listener.rs index f2e200c..62e5968 100644 --- a/src/udp_listener.rs +++ b/src/udp_listener.rs @@ -24,15 +24,9 @@ pub(crate) fn get_udp_socket(addr: A) -> io::Result socket.set_only_v6(false)?; } - // On platforms with Berkeley-derived sockets, this allows to quickly - // rebind a socket, without needing to wait for the OS to clean up the - // previous one. - // - // On Windows, this allows rebinding sockets which are actively in use, - // which allows “socket hijacking”, so we explicitly don't set it here. - // https://docs.microsoft.com/en-us/windows/win32/winsock/using-so-reuseaddr-and-so-exclusiveaddruse - #[cfg(not(windows))] socket.set_reuse_address(true)?; + #[cfg(not(windows))] + socket.set_reuse_port(true)?; socket.bind(&addr.into())?; diff --git a/tests/integration/admin_interface.rs b/tests/integration/admin_interface.rs index 0336801..36fa7fe 100644 --- a/tests/integration/admin_interface.rs +++ b/tests/integration/admin_interface.rs @@ -127,6 +127,10 @@ async fn admin_interface() { .tcpip_forward("", 23456) .await .expect("tcpip_forward failed"); + session_one + .tcpip_forward("udp.sandhole", 34567) + .await + .expect("tcpip_forward failed"); let ssh_client_two = SshClient; let mut session_two = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client_two) @@ -226,7 +230,7 @@ async fn admin_interface() { } let _ = hide_cursor_tx.send(parser.screen().hide_cursor()); }); - if timeout(Duration::from_secs(5), async move { + if timeout(Duration::from_secs(6), async move { // 4a. Validate header, system information, and HTTP tab data let search_strings: Vec = [ r"Sandhole admin v\d+\.\d+\.\d+", @@ -421,7 +425,66 @@ async fn admin_interface() { break; } } - // 4h. Close user details, switch tabs, and validate alias tab data + // 4h. Close user details, switch tabs, and validate UDP tab data + writer + .write_all(CMD_ESC) + .await + .expect("channel write failed"); + sleep(Duration::from_millis(200)).await; + writer + .write_all(CMD_TAB) + .await + .expect("channel write failed"); + let search_strings: Vec = [ + r"Sandhole admin v\d+\.\d+\.\d+", + r"System information", + r" CPU% ", + r" Memory ", + r" TX ", + r" RX ", + r"UDP services", + r"34567", + r"SHA256:GehKyA\S*", + r"127\.0\.0\.1:\d{4,5}", + ] + .into_iter() + .map(|re| Regex::new(re).expect("Invalid regex")) + .collect(); + loop { + let screen = rx.recv().await.unwrap(); + if search_strings.iter().all(|re| re.is_match(&screen)) { + break; + } + } + // 4i. View UDP user details + writer + .write_all(CMD_UP) + .await + .expect("channel write failed"); + sleep(Duration::from_millis(200)).await; + writer + .write_all(CMD_ENTER) + .await + .expect("channel write failed"); + let search_strings: Vec = [ + r"Sandhole admin v\d+\.\d+\.\d+", + r"User details", + r"SHA256:GehKyA\S*", + r"Type: User", + r"Key comment: key1", + r"Algorithm: ssh-ed25519", + r" Close Remove ", + ] + .into_iter() + .map(|re| Regex::new(re).expect("Invalid regex")) + .collect(); + loop { + let screen = rx.recv().await.unwrap(); + if search_strings.iter().all(|re| re.is_match(&screen)) { + break; + } + } + // 4j. Close user details, switch tabs, and validate alias tab data writer .write_all(CMD_ESC) .await @@ -452,7 +515,7 @@ async fn admin_interface() { break; } } - // 4i. View TCP user details + // 4k. View alias user details writer .write_all(CMD_UP) .await @@ -479,7 +542,7 @@ async fn admin_interface() { break; } } - // 4j. Close user details and go back one tab + // 4l. Close user details and go back one tab writer .write_all(CMD_ESC) .await @@ -496,8 +559,8 @@ async fn admin_interface() { r" Memory ", r" TX ", r" RX ", - r"TCP services", - r"23456", + r"UDP services", + r"34567", r"SHA256:GehKyA\S*", r"127\.0\.0\.1:\d{4,5}", ] diff --git a/tests/integration/admin_remove_user.rs b/tests/integration/admin_remove_user.rs index 6f7f46f..f49f8a5 100644 --- a/tests/integration/admin_remove_user.rs +++ b/tests/integration/admin_remove_user.rs @@ -348,6 +348,10 @@ async fn admin_remove_user() { .write_all(CMD_SHIFT_TAB) .await .expect("channel write failed"); + writer + .write_all(CMD_SHIFT_TAB) + .await + .expect("channel write failed"); let search_strings: Vec = [ r"Sandhole admin v\d+\.\d+\.\d+", r"TCP services", diff --git a/tests/integration/config_invalid_options.rs b/tests/integration/config_invalid_options.rs index f5e64a3..831e4af 100644 --- a/tests/integration/config_invalid_options.rs +++ b/tests/integration/config_invalid_options.rs @@ -16,7 +16,7 @@ use crate::common::SandholeHandle; /// Sandhole. #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn config_invalid_options() { - // 1. Fail to initialize Sandhole if HTTP, TCP, and aliasing are all disabled + // 1. Fail to initialize Sandhole if HTTP, TCP, UDP, and aliasing are all disabled let config = ApplicationConfig::parse_from([ "sandhole", "--domain=foobar.tld", @@ -49,6 +49,7 @@ async fn config_invalid_options() { "--ssh-port=18022", "--disable-http", "--disable-tcp", + "--disable-udp", "--disable-aliasing", "--acme-use-staging", ]); diff --git a/tests/integration/config_no_write_permissions_for_admin_dir.rs b/tests/integration/config_no_write_permissions_for_admin_dir.rs deleted file mode 100644 index ac70cf8..0000000 --- a/tests/integration/config_no_write_permissions_for_admin_dir.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::time::Duration; - -use clap::Parser; -use rand::{rng, seq::IndexedRandom}; -use sandhole::{ApplicationConfig, entrypoint}; -use tokio::{fs, time::timeout}; - -/// This test ensures that setting a read-only directory for the admin keys dir -/// results in an error when launching Sandhole. -#[test_log::test(tokio::test(flavor = "multi_thread"))] -async fn config_no_write_permissions_for_admin_dir() { - // 1. Create random temporary directory and fail to initialize Sandhole - let random_name = String::from_utf8( - (0..6) - .flat_map(|_| { - "0123456789abcdefghijklmnopqrstuvwxyz" - .as_bytes() - .choose(&mut rng()) - .copied() - }) - .collect(), - ) - .unwrap(); - let temp_dir = std::env::temp_dir().join(format!("sandhole_test_{random_name}")); - fs::create_dir(temp_dir.as_path()) - .await - .expect("Unable to create tempdir"); - temp_dir - .metadata() - .expect("Failed to get metadata for tempdir") - .permissions() - .set_readonly(true); - let temp_dir_path = |path: &str| temp_dir.join(path).to_string_lossy().to_string(); - let config = ApplicationConfig::parse_from([ - "sandhole", - "--domain=foobar.tld", - "--user-keys-directory", - &(format!( - "{}/tests/data/user_keys", - std::env::var("CARGO_MANIFEST_DIR").unwrap() - )), - "--admin-keys-directory", - &temp_dir_path("unwritable_user_keys"), - "--certificates-directory", - &(format!( - "{}/tests/data/certificates", - std::env::var("CARGO_MANIFEST_DIR").unwrap() - )), - "--private-key-file", - &(format!( - "{}/tests/data/server_keys/ssh", - std::env::var("CARGO_MANIFEST_DIR").unwrap() - )), - "--acme-cache-directory", - &(format!( - "{}/tests/data/acme_cache", - std::env::var("CARGO_MANIFEST_DIR").unwrap() - )), - "--listen-address=127.0.0.1", - "--ssh-port=18022", - "--disable-http", - "--disable-tcp", - "--disable-aliasing", - "--acme-use-staging", - ]); - if timeout(Duration::from_secs(5), async { - assert!(entrypoint(config).await.is_err()); - }) - .await - .is_err() - { - panic!("Timeout waiting for Sandhole to start.") - }; -} diff --git a/tests/integration/config_no_write_permissions_for_certificates_dir.rs b/tests/integration/config_no_write_permissions_for_certificates_dir.rs deleted file mode 100644 index 2aac773..0000000 --- a/tests/integration/config_no_write_permissions_for_certificates_dir.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::time::Duration; - -use clap::Parser; -use rand::{rng, seq::IndexedRandom}; -use sandhole::{ApplicationConfig, entrypoint}; -use tokio::{fs, time::timeout}; - -/// This test ensures that setting a read-only directory for the certificates -/// dir results in an error when launching Sandhole. -#[test_log::test(tokio::test(flavor = "multi_thread"))] -async fn config_no_write_permissions_for_certificates_dir() { - // 1. Create random temporary directory and fail to initialize Sandhole - let random_name = String::from_utf8( - (0..6) - .flat_map(|_| { - "0123456789abcdefghijklmnopqrstuvwxyz" - .as_bytes() - .choose(&mut rng()) - .copied() - }) - .collect(), - ) - .unwrap(); - let temp_dir = std::env::temp_dir().join(format!("sandhole_test_{random_name}")); - fs::create_dir(temp_dir.as_path()) - .await - .expect("Unable to create tempdir"); - temp_dir - .metadata() - .expect("Failed to get metadata for tempdir") - .permissions() - .set_readonly(true); - let temp_dir_path = |path: &str| temp_dir.join(path).to_string_lossy().to_string(); - let config = ApplicationConfig::parse_from([ - "sandhole", - "--domain=foobar.tld", - "--user-keys-directory", - &(format!( - "{}/tests/data/user_keys", - std::env::var("CARGO_MANIFEST_DIR").unwrap() - )), - "--admin-keys-directory", - &(format!( - "{}/tests/data/admin_keys", - std::env::var("CARGO_MANIFEST_DIR").unwrap() - )), - "--certificates-directory", - &temp_dir_path("unwritable_certificates"), - "--private-key-file", - &(format!( - "{}/tests/data/server_keys/ssh", - std::env::var("CARGO_MANIFEST_DIR").unwrap() - )), - "--acme-cache-directory", - &(format!( - "{}/tests/data/acme_cache", - std::env::var("CARGO_MANIFEST_DIR").unwrap() - )), - "--listen-address=127.0.0.1", - "--ssh-port=18022", - "--disable-http", - "--disable-tcp", - "--disable-aliasing", - "--acme-use-staging", - ]); - if timeout(Duration::from_secs(5), async { - assert!(entrypoint(config).await.is_err()); - }) - .await - .is_err() - { - panic!("Timeout waiting for Sandhole to start.") - }; -} diff --git a/tests/integration/config_no_write_permissions_for_secret_key.rs b/tests/integration/config_no_write_permissions_for_secret_key.rs index 6fd415b..77a7b59 100644 --- a/tests/integration/config_no_write_permissions_for_secret_key.rs +++ b/tests/integration/config_no_write_permissions_for_secret_key.rs @@ -6,7 +6,7 @@ use sandhole::{ApplicationConfig, entrypoint}; use tokio::{fs, time::timeout}; /// This test ensures that setting a read-only directory for the secret key -/// results in an error when launching Sandhole. +/// results in an error when launching Sandhole without a key. #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn config_no_write_permissions_for_secret_key() { // 1. Create random temporary directory and fail to initialize Sandhole @@ -58,10 +58,6 @@ async fn config_no_write_permissions_for_secret_key() { )), "--listen-address=127.0.0.1", "--ssh-port=18022", - "--disable-http", - "--disable-tcp", - "--disable-aliasing", - "--acme-use-staging", ]); if timeout(Duration::from_secs(5), async { assert!(entrypoint(config).await.is_err()); diff --git a/tests/integration/config_no_write_permissions_for_users_dir.rs b/tests/integration/config_no_write_permissions_for_users_dir.rs deleted file mode 100644 index 8249ba9..0000000 --- a/tests/integration/config_no_write_permissions_for_users_dir.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::time::Duration; - -use clap::Parser; -use rand::{rng, seq::IndexedRandom}; -use sandhole::{ApplicationConfig, entrypoint}; -use tokio::{fs, time::timeout}; - -/// This test ensures that setting a read-only directory for the user keys dir -/// results in an error when launching Sandhole. -#[test_log::test(tokio::test(flavor = "multi_thread"))] -async fn config_no_write_permissions_for_users_dir() { - // 1. Create random temporary directory and fail to initialize Sandhole - let random_name = String::from_utf8( - (0..6) - .flat_map(|_| { - "0123456789abcdefghijklmnopqrstuvwxyz" - .as_bytes() - .choose(&mut rng()) - .copied() - }) - .collect(), - ) - .unwrap(); - let temp_dir = std::env::temp_dir().join(format!("sandhole_test_{random_name}")); - fs::create_dir(temp_dir.as_path()) - .await - .expect("Unable to create tempdir"); - temp_dir - .metadata() - .expect("Failed to get metadata for tempdir") - .permissions() - .set_readonly(true); - let temp_dir_path = |path: &str| temp_dir.join(path).to_string_lossy().to_string(); - let config = ApplicationConfig::parse_from([ - "sandhole", - "--domain=foobar.tld", - "--user-keys-directory", - &temp_dir_path("unwritable_user_keys"), - "--admin-keys-directory", - &(format!( - "{}/tests/data/admin_keys", - std::env::var("CARGO_MANIFEST_DIR").unwrap() - )), - "--certificates-directory", - &(format!( - "{}/tests/data/certificates", - std::env::var("CARGO_MANIFEST_DIR").unwrap() - )), - "--private-key-file", - &(format!( - "{}/tests/data/server_keys/ssh", - std::env::var("CARGO_MANIFEST_DIR").unwrap() - )), - "--acme-cache-directory", - &(format!( - "{}/tests/data/acme_cache", - std::env::var("CARGO_MANIFEST_DIR").unwrap() - )), - "--listen-address=127.0.0.1", - "--ssh-port=18022", - "--disable-http", - "--disable-tcp", - "--disable-aliasing", - "--acme-use-staging", - ]); - if timeout(Duration::from_secs(5), async { - assert!(entrypoint(config).await.is_err()); - }) - .await - .is_err() - { - panic!("Timeout waiting for Sandhole to start.") - }; -} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index fc0263d..29002ff 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -36,10 +36,7 @@ mod config_invalid_secret_key_path; mod config_invalid_seed_file; mod config_ipv6_blocklist; mod config_missing_seed_file; -mod config_no_write_permissions_for_admin_dir; -mod config_no_write_permissions_for_certificates_dir; mod config_no_write_permissions_for_secret_key; -mod config_no_write_permissions_for_users_dir; mod connection_deny_load_balancing; mod connection_load_balancing; mod http_addressing_profanities_domain; @@ -100,6 +97,13 @@ mod tcp_reject_low_ports; mod tcp_reject_port_above_max; mod tcp_timeout; mod udp_allow_requested_ports; +mod udp_assign_random_port_0; mod udp_bind_random_ports; +mod udp_ip_connections_limit; +mod udp_multi_stream; +mod udp_no_valid_forwarding; +mod udp_rate_limit; +mod udp_reject_low_ports; +mod udp_reject_port_above_max; mod websocket_connection; mod websocket_timeout; diff --git a/tests/integration/tcp_ip_connections_limit.rs b/tests/integration/tcp_ip_connections_limit.rs index d987bc0..c290f3c 100644 --- a/tests/integration/tcp_ip_connections_limit.rs +++ b/tests/integration/tcp_ip_connections_limit.rs @@ -125,7 +125,6 @@ async fn tcp_ip_connections_limit() { assert!(tcp_stream.read_exact(&mut data).await.is_err()); // 5. Start request from different IP that succeeds - tokio::time::sleep(Duration::from_millis(1000)).await; let mut tcp_stream = TcpStream::connect("[::1]:12345") .await .expect("TCP connection failed"); diff --git a/tests/integration/tcp_multi_stream_download.rs b/tests/integration/tcp_multi_stream_download.rs index 3196282..9090d12 100644 --- a/tests/integration/tcp_multi_stream_download.rs +++ b/tests/integration/tcp_multi_stream_download.rs @@ -117,7 +117,7 @@ async fn tcp_multi_stream_download() { "authentication didn't succeed" ); session - .tcpip_forward("localhost", 12345) + .tcpip_forward("tcp.sandhole", 12345) .await .expect("tcpip_forward failed"); diff --git a/tests/integration/tcp_multi_stream_upload.rs b/tests/integration/tcp_multi_stream_upload.rs index e9dcb7a..f18558c 100644 --- a/tests/integration/tcp_multi_stream_upload.rs +++ b/tests/integration/tcp_multi_stream_upload.rs @@ -119,7 +119,7 @@ async fn tcp_multi_stream_upload() { "authentication didn't succeed" ); session - .tcpip_forward("localhost", 12345) + .tcpip_forward("tcp.sandhole", 12345) .await .expect("tcpip_forward failed"); diff --git a/tests/integration/udp_allow_requested_ports.rs b/tests/integration/udp_allow_requested_ports.rs index d7c051e..f4b4758 100644 --- a/tests/integration/udp_allow_requested_ports.rs +++ b/tests/integration/udp_allow_requested_ports.rs @@ -156,13 +156,10 @@ async fn udp_allow_requested_ports() { .await .expect("Local forwarding failed"); if timeout(Duration::from_secs(5), async { - channel - .data(&[0x00, 0x04, b'P', b'i', b'n', b'g'][..]) - .await - .unwrap(); + channel.data(&b"\x00\x04Ping"[..]).await.unwrap(); match &mut channel.wait().await.unwrap() { russh::ChannelMsg::Data { data } => { - assert_eq!(data.to_vec(), [0x00, 0x04, b'P', b'o', b'n', b'g']); + assert_eq!(data.to_vec(), b"\x00\x04Pong"); } msg => panic!("Unexpected message {msg:?}"), } @@ -208,13 +205,10 @@ async fn udp_allow_requested_ports() { .await .expect("Local forwarding failed"); if timeout(Duration::from_secs(5), async { - channel - .data(&[0x00, 0x04, b'P', b'i', b'n', b'g'][..]) - .await - .unwrap(); + channel.data(&b"\x00\x04Ping"[..]).await.unwrap(); match &mut channel.wait().await.unwrap() { russh::ChannelMsg::Data { data } => { - assert_eq!(data.to_vec(), [0x00, 0x04, b'P', b'o', b'n', b'g']); + assert_eq!(data.to_vec(), b"\x00\x04Pong"); } msg => panic!("Unexpected message {msg:?}"), } @@ -256,15 +250,11 @@ impl russh::client::Handler for SshClient { tokio::spawn(async move { match &mut channel.wait().await.unwrap() { russh::ChannelMsg::Data { data } => { - assert_eq!(data.to_vec(), [0x00, 0x04, b'P', b'i', b'n', b'g']); + assert_eq!(data.to_vec(), b"\x00\x04Ping"); } msg => panic!("Unexpected message {msg:?}"), } - channel - .data(&[0x00, 0x04, b'P', b'o', b'n', b'g'][..]) - .await - .unwrap(); - channel.eof().await.unwrap(); + channel.data(&b"\x00\x04Pong"[..]).await.unwrap(); }); Ok(()) } diff --git a/tests/integration/udp_assign_random_port_0.rs b/tests/integration/udp_assign_random_port_0.rs new file mode 100644 index 0000000..73bcd70 --- /dev/null +++ b/tests/integration/udp_assign_random_port_0.rs @@ -0,0 +1,167 @@ +use std::{sync::Arc, time::Duration}; + +use clap::Parser; +use russh::keys::{key::PrivateKeyWithHashAlg, load_secret_key}; +use russh::{ + Channel, + client::{Msg, Session}, +}; +use sandhole::{ApplicationConfig, entrypoint}; +use tokio::net::UdpSocket; +use tokio::{ + net::TcpStream, + time::{sleep, timeout}, +}; + +use crate::common::SandholeHandle; + +/// This test ensures that explicitly binding to port 0 assigns a random port +/// for UDP connections. +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn udp_assign_random_port_0() { + // 1. Initialize Sandhole + let config = ApplicationConfig::parse_from([ + "sandhole", + "--domain=foobar.tld", + "--user-keys-directory", + &(format!( + "{}/tests/data/user_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--admin-keys-directory", + &(format!( + "{}/tests/data/admin_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--certificates-directory", + &(format!( + "{}/tests/data/certificates", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--private-key-file", + &(format!( + "{}/tests/data/server_keys/ssh", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--acme-cache-directory", + &(format!( + "{}/tests/data/acme_cache", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--disable-directory-creation", + "--listen-address=127.0.0.1", + "--ssh-port=18022", + "--http-port=18080", + "--https-port=18443", + "--acme-use-staging", + "--bind-hostnames=none", + "--allow-requested-ports", + "--idle-connection-timeout=1s", + "--authentication-request-timeout=5s", + "--http-request-timeout=5s", + ]); + let _sandhole_handle = SandholeHandle(tokio::spawn(async move { entrypoint(config).await })); + if timeout(Duration::from_secs(5), async { + while TcpStream::connect("127.0.0.1:18022").await.is_err() { + sleep(Duration::from_millis(100)).await; + } + }) + .await + .is_err() + { + panic!("Timeout waiting for Sandhole to start.") + }; + + // 2. Start SSH client that will be proxied + let key = load_secret_key( + std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("tests/data/private_keys/key1"), + None, + ) + .expect("Missing file key1"); + let ssh_client = SshClient; + let mut session = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client) + .await + .expect("Failed to connect to SSH server"); + assert!( + session + .authenticate_publickey( + "user", + PrivateKeyWithHashAlg::new( + Arc::new(key), + session.best_supported_rsa_hash().await.unwrap().flatten() + ) + ) + .await + .expect("SSH authentication failed") + .success(), + "authentication didn't succeed" + ); + let Ok(port) = session.tcpip_forward("udp.sandhole", 0).await else { + panic!("tcpip_forward failed"); + }; + assert!( + ::try_from(port).expect("should be a valid port number") >= 1024, + "random port must be greater than or equal to 1024" + ); + + // 3. Connect to the UDP port of our proxy + let udp_socket = UdpSocket::bind("127.0.0.1:0") + .await + .expect("UDP connection failed"); + udp_socket + .connect(format!("127.0.0.1:{port}")) + .await + .unwrap(); + udp_socket.send(b"Ping").await.unwrap(); + let mut buf = [0u8; 32]; + if timeout(Duration::from_secs(5), async { + assert_eq!(udp_socket.recv(&mut buf).await.unwrap(), 4); + }) + .await + .is_err() + { + panic!("Timeout waiting for UDP socket to reply.") + }; + assert_eq!(&buf[..4], b"Pong"); + + // 4. Attempt to close UDP forwarding + session + .cancel_tcpip_forward("udp.sandhole", port) + .await + .expect("cancel_tcpip_forward failed"); +} + +struct SshClient; + +impl russh::client::Handler for SshClient { + type Error = color_eyre::eyre::Error; + + async fn check_server_key( + &mut self, + _key: &russh::keys::PublicKey, + ) -> Result { + Ok(true) + } + + async fn server_channel_open_forwarded_tcpip( + &mut self, + mut channel: Channel, + _connected_address: &str, + _connected_port: u32, + _originator_address: &str, + _originator_port: u32, + _session: &mut Session, + ) -> Result<(), Self::Error> { + tokio::spawn(async move { + match &mut channel.wait().await.unwrap() { + russh::ChannelMsg::Data { data } => { + assert_eq!(data.to_vec(), b"\x00\x04Ping"); + } + msg => panic!("Unexpected message {msg:?}"), + } + channel.data(&b"\x00\x04Pong"[..]).await.unwrap(); + }); + Ok(()) + } +} diff --git a/tests/integration/udp_bind_random_ports.rs b/tests/integration/udp_bind_random_ports.rs index 1e30a9c..e7c744b 100644 --- a/tests/integration/udp_bind_random_ports.rs +++ b/tests/integration/udp_bind_random_ports.rs @@ -188,15 +188,11 @@ impl russh::client::Handler for SshClient { tokio::spawn(async move { match &mut channel.wait().await.unwrap() { russh::ChannelMsg::Data { data } => { - assert_eq!(data.to_vec(), [0x00, 0x04, b'P', b'i', b'n', b'g']); + assert_eq!(data.to_vec(), b"\x00\x04Ping"); } msg => panic!("Unexpected message {msg:?}"), } - channel - .data(&[0x00, 0x04, b'P', b'o', b'n', b'g'][..]) - .await - .unwrap(); - channel.eof().await.unwrap(); + channel.data(&b"\x00\x04Pong"[..]).await.unwrap(); }); Ok(()) } diff --git a/tests/integration/udp_ip_connections_limit.rs b/tests/integration/udp_ip_connections_limit.rs new file mode 100644 index 0000000..f167e23 --- /dev/null +++ b/tests/integration/udp_ip_connections_limit.rs @@ -0,0 +1,212 @@ +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +use clap::Parser; +use russh::keys::{key::PrivateKeyWithHashAlg, load_secret_key}; +use russh::{ + Channel, + client::{Msg, Session}, +}; +use sandhole::{ApplicationConfig, entrypoint}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpStream, UdpSocket}, + time::{sleep, timeout}, +}; + +use crate::common::SandholeHandle; + +/// This test ensures that no more UDP connections from the same IP +/// than the specified limit are able to connect at the same time. +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn udp_ip_connections_limit() { + // 1. Initialize Sandhole + let config = ApplicationConfig::parse_from([ + "sandhole", + "--domain=foobar.tld", + "--user-keys-directory", + &(format!( + "{}/tests/data/user_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--admin-keys-directory", + &(format!( + "{}/tests/data/admin_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--certificates-directory", + &(format!( + "{}/tests/data/certificates", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--private-key-file", + &(format!( + "{}/tests/data/server_keys/ssh", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--acme-cache-directory", + &(format!( + "{}/tests/data/acme_cache", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--disable-directory-creation", + "--listen-address=::", + "--ssh-port=18022", + "--http-port=18080", + "--https-port=18443", + "--acme-use-staging", + "--allow-requested-ports", + "--idle-connection-timeout=1s", + "--authentication-request-timeout=5s", + "--max-simultaneous-connections-per-ip=1", + ]); + let _sandhole_handle = SandholeHandle(tokio::spawn(async move { entrypoint(config).await })); + if timeout(Duration::from_secs(5), async { + while TcpStream::connect("127.0.0.1:18022").await.is_err() { + sleep(Duration::from_millis(100)).await; + } + }) + .await + .is_err() + { + panic!("Timeout waiting for Sandhole to start.") + }; + + // 2. Start SSH client that will be proxied + let key = load_secret_key( + std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("tests/data/private_keys/key1"), + None, + ) + .expect("Missing file key1"); + let ssh_client = SshClient; + let mut session = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client) + .await + .expect("Failed to connect to SSH server"); + assert!( + session + .authenticate_publickey( + "user", + PrivateKeyWithHashAlg::new( + Arc::new(key), + session.best_supported_rsa_hash().await.unwrap().flatten() + ) + ) + .await + .expect("SSH authentication failed") + .success(), + "authentication didn't succeed" + ); + session + .tcpip_forward("udp.sandhole", 12345) + .await + .expect("tcpip_forward failed"); + + // 3. Start long-running request that takes the spot for the IP + tokio::time::sleep(Duration::from_millis(500)).await; + let started = Instant::now(); + let udp_socket = UdpSocket::bind("127.0.0.1:0") + .await + .expect("UDP connection failed"); + udp_socket + .connect(format!("127.0.0.1:12345")) + .await + .unwrap(); + udp_socket.send(b"0123456789").await.unwrap(); + let jh = tokio::spawn(async move { + let mut data = [0u8; 16]; + assert_eq!( + timeout(Duration::from_secs(6), async { + udp_socket.recv(&mut data).await.unwrap() + }) + .await + .unwrap(), + 2 + ); + assert_eq!(&data[..2], b"OK"); + }); + + // 4. Start request that gets rate-limited from IP connection exhaustion + tokio::time::sleep(Duration::from_millis(500)).await; + let udp_socket = UdpSocket::bind("127.0.0.1:0") + .await + .expect("UDP connection failed"); + udp_socket + .connect(format!("127.0.0.1:12345")) + .await + .unwrap(); + udp_socket.send(b"0123456789").await.unwrap(); + let jh2 = tokio::spawn(async move { + let mut data = [0u8; 16]; + assert!( + timeout(Duration::from_secs(6), async { + udp_socket.recv(&mut data).await.unwrap() + }) + .await + .is_err() + ); + }); + + // 5. Start request from different IP that succeeds + let udp_socket = UdpSocket::bind("[::1]:0") + .await + .expect("UDP connection failed"); + udp_socket.connect(format!("[::1]:12345")).await.unwrap(); + udp_socket.send(b"0123456789").await.unwrap(); + assert!(started.elapsed() < Duration::from_secs(5)); + let mut data = [0u8; 16]; + assert_eq!( + timeout(Duration::from_secs(6), async { + udp_socket.recv(&mut data).await.unwrap() + }) + .await + .unwrap(), + 2 + ); + assert!(started.elapsed() > Duration::from_secs(5)); + assert_eq!(&data[..2], b"OK"); + + timeout(Duration::from_secs(10), async move { + jh.await.unwrap(); + jh2.await.unwrap(); + }) + .await + .expect("timeout waiting for join handle to finish"); +} + +struct SshClient; + +impl russh::client::Handler for SshClient { + type Error = color_eyre::eyre::Error; + + async fn check_server_key( + &mut self, + _key: &russh::keys::PublicKey, + ) -> Result { + Ok(true) + } + + async fn server_channel_open_forwarded_tcpip( + &mut self, + channel: Channel, + _connected_address: &str, + _connected_port: u32, + _originator_address: &str, + _originator_port: u32, + _session: &mut Session, + ) -> Result<(), Self::Error> { + tokio::spawn(async move { + let mut stream = channel.into_stream(); + let len = stream.read_u16().await.unwrap(); + let mut buf = [0; 32]; + stream.read_exact(&mut buf[..len as usize]).await.unwrap(); + assert_eq!(&buf[..len as usize], b"0123456789"); + tokio::time::sleep(Duration::from_secs(5)).await; + stream.write_all(&b"\x00\x02OK"[..]).await.unwrap(); + stream.flush().await.unwrap(); + }); + Ok(()) + } +} diff --git a/tests/integration/udp_multi_stream.rs b/tests/integration/udp_multi_stream.rs new file mode 100644 index 0000000..eae5f91 --- /dev/null +++ b/tests/integration/udp_multi_stream.rs @@ -0,0 +1,207 @@ +use std::{sync::Arc, time::Duration}; + +use clap::Parser; + +use rand::Rng; +use russh::{ + Channel, Preferred, + client::{Msg, Session}, +}; +use russh::{ + client::Config, + keys::{key::PrivateKeyWithHashAlg, load_secret_key}, +}; +use sandhole::{ApplicationConfig, entrypoint}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpStream, UdpSocket}, + time::{sleep, timeout}, +}; + +use crate::common::SandholeHandle; + +/// This test ensures that an UDP service can handle multiple big requests at the +/// same time (mostly for profiling purposes). +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn udp_multi_stream() { + // 1. Initialize Sandhole + let config = ApplicationConfig::parse_from([ + "sandhole", + "--domain=foobar.tld", + "--user-keys-directory", + &(format!( + "{}/tests/data/user_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--admin-keys-directory", + &(format!( + "{}/tests/data/admin_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--certificates-directory", + &(format!( + "{}/tests/data/certificates", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--private-key-file", + &(format!( + "{}/tests/data/server_keys/ssh", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--acme-cache-directory", + &(format!( + "{}/tests/data/acme_cache", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--disable-directory-creation", + "--listen-address=127.0.0.1", + "--ssh-port=18022", + "--http-port=18080", + "--https-port=18443", + "--acme-use-staging", + "--bind-hostnames=none", + "--allow-requested-ports", + "--idle-connection-timeout=1s", + "--authentication-request-timeout=5s", + "--http-request-timeout=60s", + ]); + let _sandhole_handle = SandholeHandle(tokio::spawn(async move { entrypoint(config).await })); + if timeout(Duration::from_secs(5), async { + while TcpStream::connect("127.0.0.1:18022").await.is_err() { + sleep(Duration::from_millis(100)).await; + } + }) + .await + .is_err() + { + panic!("Timeout waiting for Sandhole to start.") + }; + + // 2. Start SSH client that will be proxied + let key = load_secret_key( + std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("tests/data/private_keys/key1"), + None, + ) + .expect("Missing file key1"); + let ssh_client = SshClient; + let mut session = russh::client::connect( + Arc::new(Config { + preferred: Preferred { + cipher: std::borrow::Cow::Borrowed(&[ + russh::cipher::CHACHA20_POLY1305, + // russh::cipher::AES_256_GCM, + ]), + ..Default::default() + }, + ..Default::default() + }), + "127.0.0.1:18022", + ssh_client, + ) + .await + .expect("Failed to connect to SSH server"); + assert!( + session + .authenticate_publickey( + "user", + PrivateKeyWithHashAlg::new( + Arc::new(key), + session.best_supported_rsa_hash().await.unwrap().flatten() + ) + ) + .await + .expect("SSH authentication failed") + .success(), + "authentication didn't succeed" + ); + session + .tcpip_forward("udp.sandhole", 12345) + .await + .expect("tcpip_forward failed"); + + // 3. Connect to the UDP port of our proxy with out multiple streams + let mut data = vec![0u8; 20_000_000]; + rand::rng().fill_bytes(&mut data); + let data: &'static [u8] = data.leak(); + timeout(Duration::from_secs(30), async move { + let mut jh_vec = vec![]; + for file_size in [7_500_000usize, 10_000_000, 15_000_000, 20_000_000] { + let udp_socket_read = Arc::new( + UdpSocket::bind("127.0.0.1:0") + .await + .expect("UDP connection failed"), + ); + udp_socket_read + .connect(format!("127.0.0.1:12345")) + .await + .unwrap(); + let udp_socket_write = Arc::clone(&udp_socket_read); + let jh = tokio::spawn(async move { + let jh = tokio::spawn(async move { + let mut buf = [0u8; 32]; + for chunk in data[..file_size].chunks(548) { + udp_socket_write.send(chunk).await.unwrap(); + assert_eq!( + udp_socket_read + .recv(&mut buf) + .await + .expect("socket closed unexpectedly"), + size_of::() + ); + assert_eq!( + u16::from_be_bytes( + *buf[..size_of::()].as_array().expect("size checked") + ) as usize, + chunk.len() + ); + } + }); + jh.abort(); + }); + jh_vec.push(jh); + } + for jh in jh_vec.into_iter() { + jh.await.expect("Join handle panicked"); + } + }) + .await + .expect("Timeout waiting for test to finish."); +} + +struct SshClient; + +impl russh::client::Handler for SshClient { + type Error = color_eyre::eyre::Error; + + async fn check_server_key( + &mut self, + _key: &russh::keys::PublicKey, + ) -> Result { + Ok(true) + } + + async fn server_channel_open_forwarded_tcpip( + &mut self, + channel: Channel, + _connected_address: &str, + _connected_port: u32, + _originator_address: &str, + _originator_port: u32, + _session: &mut Session, + ) -> Result<(), Self::Error> { + tokio::spawn(async move { + let mut buf = [0u8; u16::MAX as usize]; + let mut stream = channel.into_stream(); + let n = stream.read_u16().await.unwrap(); + stream.read_exact(&mut buf[..n as usize]).await.unwrap(); + stream + .write_all(&size_of::().to_be_bytes()[..]) + .await + .unwrap(); + stream.write_all(&n.to_be_bytes()[..]).await.unwrap(); + stream.flush().await.unwrap(); + }); + Ok(()) + } +} diff --git a/tests/integration/udp_no_valid_forwarding.rs b/tests/integration/udp_no_valid_forwarding.rs new file mode 100644 index 0000000..40a1141 --- /dev/null +++ b/tests/integration/udp_no_valid_forwarding.rs @@ -0,0 +1,121 @@ +use std::{sync::Arc, time::Duration}; + +use clap::Parser; +use russh::keys::{key::PrivateKeyWithHashAlg, load_secret_key}; +use sandhole::{ApplicationConfig, entrypoint}; +use tokio::{ + net::TcpStream, + time::{sleep, timeout}, +}; + +use crate::common::SandholeHandle; + +/// This test ensures that local forwarding fails if there is no corresponding +/// UDP connection. +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn udp_no_valid_forwarding() { + // 1. Initialize Sandhole + let config = ApplicationConfig::parse_from([ + "sandhole", + "--domain=foobar.tld", + "--user-keys-directory", + &(format!( + "{}/tests/data/user_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--admin-keys-directory", + &(format!( + "{}/tests/data/admin_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--certificates-directory", + &(format!( + "{}/tests/data/certificates", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--private-key-file", + &(format!( + "{}/tests/data/server_keys/ssh", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--acme-cache-directory", + &(format!( + "{}/tests/data/acme_cache", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--disable-directory-creation", + "--listen-address=127.0.0.1", + "--ssh-port=18022", + "--http-port=18080", + "--https-port=18443", + "--acme-use-staging", + "--bind-hostnames=none", + "--idle-connection-timeout=1s", + "--authentication-request-timeout=5s", + "--http-request-timeout=5s", + ]); + let _sandhole_handle = SandholeHandle(tokio::spawn(async move { entrypoint(config).await })); + if timeout(Duration::from_secs(5), async { + while TcpStream::connect("127.0.0.1:18022").await.is_err() { + sleep(Duration::from_millis(100)).await; + } + }) + .await + .is_err() + { + panic!("Timeout waiting for Sandhole to start.") + }; + + // 2. Start SSH client that will fail to local forward + let key = load_secret_key( + std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("tests/data/private_keys/key1"), + None, + ) + .expect("Missing file key1"); + let ssh_client = SshClient; + let mut session = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client) + .await + .expect("Failed to connect to SSH server"); + assert!( + session + .authenticate_publickey( + "user", + PrivateKeyWithHashAlg::new( + Arc::new(key), + session.best_supported_rsa_hash().await.unwrap().flatten() + ) + ) + .await + .expect("SSH authentication failed") + .success(), + "authentication didn't succeed" + ); + assert!( + session + .channel_open_direct_tcpip("udp.sandhole", 4040, "127.0.0.1", 12345) + .await + .is_err(), + "tcpip_forward should've failed" + ); + assert!( + session + .cancel_tcpip_forward("udp.sandhole", 4030) + .await + .is_err(), + "cancel_tcpip_forward should've failed" + ) +} + +struct SshClient; + +impl russh::client::Handler for SshClient { + type Error = color_eyre::eyre::Error; + + async fn check_server_key( + &mut self, + _key: &russh::keys::PublicKey, + ) -> Result { + Ok(true) + } +} diff --git a/tests/integration/udp_rate_limit.rs b/tests/integration/udp_rate_limit.rs new file mode 100644 index 0000000..afc9ae1 --- /dev/null +++ b/tests/integration/udp_rate_limit.rs @@ -0,0 +1,176 @@ +use std::time::Instant; +use std::{sync::Arc, time::Duration}; + +use clap::Parser; + +use rand::Rng; +use russh::ChannelMsg; +use russh::{ + Channel, + client::{Msg, Session}, + keys::{key::PrivateKeyWithHashAlg, load_secret_key}, +}; +use sandhole::{ApplicationConfig, entrypoint}; +use tokio::net::UdpSocket; +use tokio::{ + net::TcpStream, + time::{sleep, timeout}, +}; + +use crate::common::SandholeHandle; + +/// This test ensures that rate limiting works as expected for UDP +/// services. +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn udp_rate_limit() { + // 1. Initialize Sandhole + let config = ApplicationConfig::parse_from([ + "sandhole", + "--domain=foobar.tld", + "--user-keys-directory", + &(format!( + "{}/tests/data/user_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--admin-keys-directory", + &(format!( + "{}/tests/data/admin_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--certificates-directory", + &(format!( + "{}/tests/data/certificates", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--private-key-file", + &(format!( + "{}/tests/data/server_keys/ssh", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--acme-cache-directory", + &(format!( + "{}/tests/data/acme_cache", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--disable-directory-creation", + "--listen-address=127.0.0.1", + "--ssh-port=18022", + "--http-port=18080", + "--https-port=18443", + "--acme-use-staging", + "--bind-hostnames=none", + "--allow-requested-ports", + "--idle-connection-timeout=1s", + "--authentication-request-timeout=5s", + "--http-request-timeout=5s", + "--buffer-size=20KB", + "--rate-limit-per-user=20KB", + ]); + let _sandhole_handle = SandholeHandle(tokio::spawn(async move { entrypoint(config).await })); + if timeout(Duration::from_secs(5), async { + while TcpStream::connect("127.0.0.1:18022").await.is_err() { + sleep(Duration::from_millis(100)).await; + } + }) + .await + .is_err() + { + panic!("Timeout waiting for Sandhole to start.") + }; + + // 2. Start SSH client that will be proxied + let key = load_secret_key( + std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("tests/data/private_keys/key1"), + None, + ) + .expect("Missing file key1"); + let ssh_client = SshClient; + let mut session_one = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client) + .await + .expect("Failed to connect to SSH server"); + assert!( + session_one + .authenticate_publickey( + "user", + PrivateKeyWithHashAlg::new( + Arc::new(key), + session_one + .best_supported_rsa_hash() + .await + .unwrap() + .flatten() + ) + ) + .await + .expect("SSH authentication failed") + .success(), + "authentication didn't succeed" + ); + session_one + .tcpip_forward("udp.sandhole", 12345) + .await + .expect("tcpip_forward failed"); + + // 3. Connect to the UDP port of our proxy + let mut data = vec![0u8; 55_000]; + rand::rng().fill_bytes(&mut data); + let udp_socket = UdpSocket::bind("127.0.0.1:0") + .await + .expect("UDP connection failed"); + udp_socket + .connect(format!("127.0.0.1:12345")) + .await + .unwrap(); + let start = Instant::now(); + udp_socket.send(&data[..]).await.unwrap(); + let mut buf = [0u8; 32]; + assert_eq!(udp_socket.recv(&mut buf).await.unwrap(), 2); + let elapsed = start.elapsed(); + assert_eq!(&buf[..2], b"OK"); + assert!( + elapsed > Duration::from_millis(2_000), + "must've taken more than 2 seconds, but was {elapsed:?}" + ); + assert!( + elapsed < Duration::from_millis(3_500), + "must've taken less than 3.5 seconds, but was {elapsed:?}" + ); +} + +struct SshClient; + +impl russh::client::Handler for SshClient { + type Error = color_eyre::eyre::Error; + + async fn check_server_key( + &mut self, + _key: &russh::keys::PublicKey, + ) -> Result { + Ok(true) + } + + async fn server_channel_open_forwarded_tcpip( + &mut self, + mut channel: Channel, + _connected_address: &str, + _connected_port: u32, + _originator_address: &str, + _originator_port: u32, + _session: &mut Session, + ) -> Result<(), Self::Error> { + tokio::spawn(async move { + let mut expected_len = 55_000 + std::mem::size_of::(); + while let Some(msg) = channel.wait().await { + if let ChannelMsg::Data { data } = msg { + expected_len -= data.len(); + if expected_len == 0 { + channel.data(&b"\x00\x02OK"[..]).await.unwrap(); + return; + } + } + } + }); + Ok(()) + } +} diff --git a/tests/integration/udp_reject_low_ports.rs b/tests/integration/udp_reject_low_ports.rs new file mode 100644 index 0000000..31efdaa --- /dev/null +++ b/tests/integration/udp_reject_low_ports.rs @@ -0,0 +1,138 @@ +use std::{sync::Arc, time::Duration}; + +use clap::Parser; +use russh::keys::{key::PrivateKeyWithHashAlg, load_secret_key}; +use russh::{ + Channel, + client::{Msg, Session}, +}; +use sandhole::{ApplicationConfig, entrypoint}; +use tokio::{ + net::TcpStream, + time::{sleep, timeout}, +}; + +use crate::common::SandholeHandle; + +/// This test ensures that remote forwarding is not allowed for low UDP ports. +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn udp_reject_low_ports() { + // 1. Initialize Sandhole + let config = ApplicationConfig::parse_from([ + "sandhole", + "--domain=foobar.tld", + "--user-keys-directory", + &(format!( + "{}/tests/data/user_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--admin-keys-directory", + &(format!( + "{}/tests/data/admin_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--certificates-directory", + &(format!( + "{}/tests/data/certificates", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--private-key-file", + &(format!( + "{}/tests/data/server_keys/ssh", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--acme-cache-directory", + &(format!( + "{}/tests/data/acme_cache", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--disable-directory-creation", + "--listen-address=127.0.0.1", + "--ssh-port=18022", + "--http-port=18080", + "--https-port=18443", + "--acme-use-staging", + "--bind-hostnames=none", + "--allow-requested-ports", + "--idle-connection-timeout=1s", + "--authentication-request-timeout=5s", + "--http-request-timeout=5s", + ]); + let _sandhole_handle = SandholeHandle(tokio::spawn(async move { entrypoint(config).await })); + if timeout(Duration::from_secs(5), async { + while TcpStream::connect("127.0.0.1:18022").await.is_err() { + sleep(Duration::from_millis(100)).await; + } + }) + .await + .is_err() + { + panic!("Timeout waiting for Sandhole to start.") + }; + + // 2. Start SSH client that will fail to bind + let key = load_secret_key( + std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("tests/data/private_keys/key1"), + None, + ) + .expect("Missing file key1"); + let ssh_client = SshClient; + let mut session = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client) + .await + .expect("Failed to connect to SSH server"); + assert!( + session + .authenticate_publickey( + "user", + PrivateKeyWithHashAlg::new( + Arc::new(key), + session.best_supported_rsa_hash().await.unwrap().flatten() + ) + ) + .await + .expect("SSH authentication failed") + .success(), + "authentication didn't succeed" + ); + assert!( + session.tcpip_forward("udp.sandhole", 20555).await.is_ok(), + "should allow binding on high port" + ); + assert!( + session.tcpip_forward("udp.sandhole", 1024).await.is_ok(), + "should allow binding ports greater than or equal to 1024" + ); + assert!( + session.tcpip_forward("udp.sandhole", 1023).await.is_err(), + "shouldn't allow binding ports under 1024" + ); +} + +struct SshClient; + +impl russh::client::Handler for SshClient { + type Error = color_eyre::eyre::Error; + + async fn check_server_key( + &mut self, + _key: &russh::keys::PublicKey, + ) -> Result { + Ok(true) + } + + async fn server_channel_open_forwarded_tcpip( + &mut self, + channel: Channel, + _connected_address: &str, + _connected_port: u32, + _originator_address: &str, + _originator_port: u32, + _session: &mut Session, + ) -> Result<(), Self::Error> { + tokio::spawn(async move { + channel.data(&b"\x00\x13Hello, world!"[..]).await.unwrap(); + }); + Ok(()) + } +} diff --git a/tests/integration/udp_reject_port_above_max.rs b/tests/integration/udp_reject_port_above_max.rs new file mode 100644 index 0000000..b571c31 --- /dev/null +++ b/tests/integration/udp_reject_port_above_max.rs @@ -0,0 +1,210 @@ +use std::{sync::Arc, time::Duration}; + +use clap::Parser; +use russh::keys::{key::PrivateKeyWithHashAlg, load_secret_key}; +use russh::{ + Channel, + client::{Msg, Session}, +}; +use sandhole::{ApplicationConfig, entrypoint}; +use tokio::{ + net::TcpStream, + time::{sleep, timeout}, +}; + +use crate::common::SandholeHandle; + +/// This test ensures that forwarding is not allowed for UDP ports above the +/// u16 limit. +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn udp_reject_port_above_max() { + // 1. Initialize Sandhole + let config = ApplicationConfig::parse_from([ + "sandhole", + "--domain=foobar.tld", + "--user-keys-directory", + &(format!( + "{}/tests/data/user_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--admin-keys-directory", + &(format!( + "{}/tests/data/admin_keys", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--certificates-directory", + &(format!( + "{}/tests/data/certificates", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--private-key-file", + &(format!( + "{}/tests/data/server_keys/ssh", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--acme-cache-directory", + &(format!( + "{}/tests/data/acme_cache", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + )), + "--disable-directory-creation", + "--listen-address=127.0.0.1", + "--ssh-port=18022", + "--http-port=18080", + "--https-port=18443", + "--acme-use-staging", + "--bind-hostnames=none", + "--allow-requested-ports", + "--idle-connection-timeout=1s", + "--authentication-request-timeout=5s", + "--http-request-timeout=5s", + ]); + let _sandhole_handle = SandholeHandle(tokio::spawn(async move { entrypoint(config).await })); + if timeout(Duration::from_secs(5), async { + while TcpStream::connect("127.0.0.1:18022").await.is_err() { + sleep(Duration::from_millis(100)).await; + } + }) + .await + .is_err() + { + panic!("Timeout waiting for Sandhole to start.") + }; + + // 2. Start SSH client that will fail to bind + let key = load_secret_key( + std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("tests/data/private_keys/key1"), + None, + ) + .expect("Missing file key1"); + let ssh_client = SshClient; + let mut session = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client) + .await + .expect("Failed to connect to SSH server"); + assert!( + session + .authenticate_publickey( + "user", + PrivateKeyWithHashAlg::new( + Arc::new(key), + session.best_supported_rsa_hash().await.unwrap().flatten() + ) + ) + .await + .expect("SSH authentication failed") + .success(), + "authentication didn't succeed" + ); + assert!( + session + .tcpip_forward("udp.sandhole", u16::MAX.into()) + .await + .is_ok(), + "should allow binding ports lesser than or equal to 65535" + ); + assert!( + session + .tcpip_forward("udp.sandhole", 1u32 + u16::MAX as u32) + .await + .is_err(), + "shouldn't allow binding ports above 65535" + ); + assert!(session.is_closed()); + + // 3. Start SSH client that will fail to cancel a remote forwarding + let key = load_secret_key( + std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("tests/data/private_keys/key1"), + None, + ) + .expect("Missing file key1"); + let ssh_client = SshClient; + let mut session = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client) + .await + .expect("Failed to connect to SSH server"); + assert!( + session + .authenticate_publickey( + "user", + PrivateKeyWithHashAlg::new( + Arc::new(key), + session.best_supported_rsa_hash().await.unwrap().flatten() + ) + ) + .await + .expect("SSH authentication failed") + .success(), + "authentication didn't succeed" + ); + assert!( + session + .cancel_tcpip_forward("udp.sandhole", 1u32 + u16::MAX as u32) + .await + .is_err(), + "shouldn't allow unbinding ports above 65535" + ); + assert!(session.is_closed()); + + // 4. Start SSH clients that will fail to local forward an invalid port + let key = load_secret_key( + std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("tests/data/private_keys/key1"), + None, + ) + .expect("Missing file key1"); + let ssh_client = SshClient; + let mut session = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client) + .await + .expect("Failed to connect to SSH server"); + assert!( + session + .authenticate_publickey( + "user", + PrivateKeyWithHashAlg::new( + Arc::new(key), + session.best_supported_rsa_hash().await.unwrap().flatten() + ) + ) + .await + .expect("SSH authentication failed") + .success(), + "authentication didn't succeed" + ); + assert!( + session + .channel_open_direct_tcpip("udp.sandhole", 1u32 + u16::MAX as u32, "127.0.0.1", 12345) + .await + .is_err(), + "shouldn't allow binding ports above 65535" + ); + assert!(session.is_closed()); +} + +struct SshClient; + +impl russh::client::Handler for SshClient { + type Error = color_eyre::eyre::Error; + + async fn check_server_key( + &mut self, + _key: &russh::keys::PublicKey, + ) -> Result { + Ok(true) + } + + async fn server_channel_open_forwarded_tcpip( + &mut self, + channel: Channel, + _connected_address: &str, + _connected_port: u32, + _originator_address: &str, + _originator_port: u32, + _session: &mut Session, + ) -> Result<(), Self::Error> { + tokio::spawn(async move { + channel.data(&b"\x00\x13Hello, world!"[..]).await.unwrap(); + }); + Ok(()) + } +}