// 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 rustls::{ClientConfig, client::danger::ServerCertVerifier};
use rustls_acme::acme::ACME_TLS_ALPN_NAME;
use sandhole::{ApplicationConfig, entrypoint};
use tokio::{
net::TcpStream,
time::{sleep, timeout},
};
use tokio_rustls::TlsConnector;
use crate::common::SandholeHandle;
/// This test ensures that invalid options result in errors when running
/// Sandhole.
#[test_log::test(tokio::test(flavor = "multi_thread"))]
async fn config_invalid_options() {
// 1. Fail to initialize Sandhole if HTTP, TCP, UDP, and aliasing are all disabled
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()
)),
"--listen-address=127.0.0.1",
"--ssh-port=18022",
"--disable-http",
"--disable-tcp",
"--disable-udp",
"--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.")
};
// 2. Fail to initialize with --bind-hostnames=cname if --no-domain is set
let config = ApplicationConfig::parse_from([
"sandhole",
"--no-domain",
"--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()
)),
"--listen-address=127.0.0.1",
"--ssh-port=18022",
"--http-port=18080",
"--https-port=18443",
"--bind-hostnames=cname",
]);
if timeout(Duration::from_secs(5), async {
assert!(entrypoint(config).await.is_err());
})
.await
.is_err()
{
panic!("Timeout waiting for Sandhole to start.")
};
// 3a. Fail to initialize ACME ALPN resolver if HTTPS port is not 443
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()
)),
"--listen-address=127.0.0.1",
"--ssh-port=18022",
"--http-port=18080",
"--https-port=18443",
"--acme-use-staging",
"--acme-contact-email=someone@github.com",
"--bind-hostnames=all",
]);
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.")
};
// 3b. Fail to connect with fake TLS-ALPN-01 challenge verifier
let mut tls_config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(SkipServerVerification))
.with_no_client_auth();
tls_config.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec());
let tls_config = Arc::new(tls_config);
let connector = TlsConnector::from(tls_config);
let tcp_stream = TcpStream::connect("127.0.0.1:18443")
.await
.expect("TCP connection failed");
assert!(
connector
.connect("test.foobar.tld".try_into().unwrap(), tcp_stream)
.await
.is_err()
);
}
#[derive(Debug)]
struct SkipServerVerification;
impl ServerCertVerifier for SkipServerVerification {
fn verify_server_cert(
&self,
_end_entity: &rustls_pki_types::CertificateDer<'_>,
_intermediates: &[rustls_pki_types::CertificateDer<'_>],
_server_name: &rustls_pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls_pki_types::UnixTime,
) -> Result {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA1,
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}