// 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::{sync::Arc, time::Duration};
use clap::Parser;
use russh::ChannelId;
use russh::client::ChannelOpenHandle;
use russh::keys::{key::PrivateKeyWithHashAlg, load_secret_key};
use russh::{
Channel,
client::{self, Msg, Session},
};
use sandhole::{ApplicationConfig, entrypoint};
use tokio::{
net::TcpStream,
sync::mpsc,
time::{sleep, timeout},
};
use crate::common::SandholeHandle;
/// This test ensures that an admin user cannot access the admin interface and
/// perform local forwarding at the same time.
#[test_log::test(tokio::test(flavor = "multi_thread"))]
async fn admin_no_interface_if_proxying() {
// 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",
"--allow-requested-ports",
"--idle-connection-timeout=800ms",
"--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"
);
session
.tcpip_forward("proxy.ccc", 12345)
.await
.expect("tcpip_forward failed");
// 3. Create forwarding and fail to open admin interface
let key = load_secret_key(
std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("tests/data/private_keys/admin"),
None,
)
.expect("Missing file admin");
let (tx, mut rx) = mpsc::unbounded_channel();
let ssh_client = SshClientAdmin(tx);
let mut session = client::connect(Default::default(), "127.0.0.1:18022", ssh_client)
.await
.expect("Failed to connect to SSH server");
assert!(
session
.authenticate_publickey(
"admin",
PrivateKeyWithHashAlg::new(
Arc::new(key),
session.best_supported_rsa_hash().await.unwrap().flatten()
)
)
.await
.expect("SSH authentication failed")
.success(),
"authentication didn't succeed"
);
let _alias_channel = session
.channel_open_direct_tcpip("proxy.ccc", 12345, "::1", 23456)
.await
.expect("Local forwarding failed");
let channel = session
.channel_open_session()
.await
.expect("channel_open_session failed");
channel
.exec(true, "admin")
.await
.expect("exec admin failed");
let Ok(channel_id) = timeout(Duration::from_secs(2), async { rx.recv().await.unwrap() }).await
else {
panic!("Timeout waiting for server to reply.");
};
assert_eq!(channel_id, channel.id());
assert!(rx.is_empty(), "rx shouldn't have any remaining messages");
sleep(Duration::from_millis(200)).await;
assert!(session.is_closed(), "session should've been closed");
// 3. Open admin interface and fail to create forwarding
let key = load_secret_key(
std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("tests/data/private_keys/admin"),
None,
)
.expect("Missing file admin");
let ssh_client = SshClient;
let mut session = client::connect(Default::default(), "127.0.0.1:18022", ssh_client)
.await
.expect("Failed to connect to SSH server");
assert!(
session
.authenticate_publickey(
"admin",
PrivateKeyWithHashAlg::new(
Arc::new(key),
session.best_supported_rsa_hash().await.unwrap().flatten()
)
)
.await
.expect("SSH authentication failed")
.success(),
"authentication didn't succeed"
);
let channel = session
.channel_open_session()
.await
.expect("channel_open_session failed");
channel
.exec(false, "admin")
.await
.expect("exec admin failed");
assert!(
session
.channel_open_direct_tcpip("proxy.ccc", 12345, "::1", 23456)
.await
.is_err(),
"proxying should've failed"
);
sleep(Duration::from_millis(200)).await;
assert!(session.is_closed(), "session should've been 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,
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
tokio::spawn(async move {
channel.data(&b"Hello, world!"[..]).await.unwrap();
sleep(Duration::from_secs(1)).await;
channel.eof().await.unwrap();
});
reply.accept().await;
Ok(())
}
}
struct SshClientAdmin(mpsc::UnboundedSender);
impl russh::client::Handler for SshClientAdmin {
type Error = color_eyre::eyre::Error;
async fn check_server_key(
&mut self,
_key: &russh::keys::PublicKey,
) -> Result {
Ok(true)
}
async fn channel_failure(
&mut self,
channel: ChannelId,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
self.0.send(channel).unwrap();
Ok(())
}
}