// sandhole: Expose HTTP/SSH/TCP services through SSH port forwarding
// Copyright (C) 2024-2026 Eric Rodrigues Pires
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
// more details.
//
// You should have received a copy of the GNU Affero General Public License along
// with this program. If not, see .
use std::{
net::{IpAddr, SocketAddr},
str::FromStr,
sync::Arc,
time::{Duration, Instant},
};
use clap::Parser;
use rand::{Rng, RngExt, SeedableRng};
use rand_chacha::ChaCha20Rng;
use russh::{
Channel, MethodSet,
client::{self, ChannelOpenHandle, Msg},
keys::ssh_key::private::Ed25519Keypair,
server::{self, Auth, Server},
};
use russh::{
MethodKind,
keys::{key::PrivateKeyWithHashAlg, load_secret_key},
};
use sandhole::{ApplicationConfig, entrypoint};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
time::{sleep, timeout},
};
use crate::common::SandholeHandle;
/// This test ensures that upload rate limiting works as expected for SSH
/// services.
#[test_log::test(tokio::test(flavor = "multi_thread"))]
async fn ssh_rate_limit_upload() {
// 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=all",
"--idle-connection-timeout=2s",
"--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 via HTTPS
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 { server: Honeypot };
let mut session_one = 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("test.foobar.tld", 22)
.await
.expect("tcpip_forward failed");
// 3. Connect to the SSH port of our proxy with anonymous user
let key = russh::keys::PrivateKey::from(Ed25519Keypair::from_seed(
&ChaCha20Rng::from_rng(&mut rand::rng()).random(),
));
let ssh_client = ProxyClient;
let mut session_two = client::connect(Default::default(), "127.0.0.1:18022", ssh_client)
.await
.expect("Failed to connect to SSH server");
assert!(
session_two
.authenticate_publickey(
"user1",
PrivateKeyWithHashAlg::new(
Arc::new(key),
session_two
.best_supported_rsa_hash()
.await
.unwrap()
.flatten()
)
)
.await
.expect("SSH authentication failed")
.success(),
"authentication didn't succeed"
);
let channel = session_two
.channel_open_direct_tcpip("test.foobar.tld", 18022, "::1", 12345)
.await
.expect("Local forwarding failed");
let fake_socket = channel.into_stream();
let start = Instant::now();
let new_ssh_client = ProxyClient;
let mut proxy_session = client::connect_stream(Default::default(), fake_socket, new_ssh_client)
.await
.expect("Failed to connect to proxied SSH server");
assert!(
proxy_session
.authenticate_password("user", "password")
.await
.expect("Proxy SSH authentication failed")
.success(),
"authentication didn't succeed"
);
let session_channel = proxy_session
.channel_open_session()
.await
.expect("Failed to open session to proxied SSH server");
let mut data = vec![0u8; 55_000];
let mut stream = session_channel.into_stream();
stream.read_exact(&mut data).await.unwrap();
let elapsed = start.elapsed();
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 {
server: Honeypot,
}
impl 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,
reply: ChannelOpenHandle,
_session: &mut client::Session,
) -> Result<(), Self::Error> {
let handler = self.server.new_client(
IpAddr::from_str(connected_address)
.ok()
.map(|addr| SocketAddr::new(addr, connected_port as u16)),
);
let stream = channel.into_stream();
let keys = vec![russh::keys::PrivateKey::from(Ed25519Keypair::from_seed(
&ChaCha20Rng::from_rng(&mut rand::rng()).random(),
))];
tokio::spawn(async move {
let session = match server::run_stream(
Arc::new(server::Config {
keys,
..Default::default()
}),
stream,
handler,
)
.await
{
Ok(session) => session,
Err(_) => {
// Connection setup failed
return;
}
};
match session.await {
Ok(_) => (),
Err(_) => {
// Connection closed with error
}
}
});
reply.accept().await;
Ok(())
}
}
struct Honeypot;
impl server::Server for Honeypot {
type Handler = HoneypotHandler;
fn new_client(&mut self, _peer_addr: Option) -> Self::Handler {
HoneypotHandler
}
}
struct HoneypotHandler;
impl server::Handler for HoneypotHandler {
type Error = russh::Error;
async fn auth_publickey(
&mut self,
_user: &str,
_public_key: &russh::keys::PublicKey,
) -> Result {
Ok(Auth::Reject {
proceed_with_methods: Some(MethodSet::from([MethodKind::Password].as_slice())),
partial_success: false,
})
}
async fn auth_password(&mut self, user: &str, password: &str) -> Result {
if user == "user" && password == "password" {
Ok(Auth::Accept)
} else {
Ok(Auth::Reject {
proceed_with_methods: None,
partial_success: false,
})
}
}
async fn channel_open_session(
&mut self,
channel: russh::Channel,
reply: russh::server::ChannelOpenHandle,
_session: &mut server::Session,
) -> Result<(), Self::Error> {
let mut data = vec![0u8; 55_000];
rand::rng().fill_bytes(&mut data);
tokio::spawn(async move {
let mut stream = channel.into_stream();
stream.write_all(&data).await.unwrap();
stream.flush().await.unwrap();
});
reply.accept().await;
Ok(())
}
}
struct ProxyClient;
impl client::Handler for ProxyClient {
type Error = russh::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,
reply: ChannelOpenHandle,
_session: &mut client::Session,
) -> Result<(), Self::Error> {
let handler = Honeypot.new_client(
IpAddr::from_str(connected_address)
.ok()
.map(|addr| SocketAddr::new(addr, connected_port as u16)),
);
let stream = channel.into_stream();
tokio::spawn(async move {
let session = match server::run_stream(Default::default(), stream, handler).await {
Ok(session) => session,
Err(_) => {
// Connection setup failed
return;
}
};
match session.await {
Ok(_) => (),
Err(_) => {
// Connection closed with error
}
}
});
reply.accept().await;
Ok(())
}
}