diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -1488,6 +1488,12 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] name = "hyper" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2920,6 +2926,7 @@ "futures-util", "hickory-resolver", "http-body-util", + "humantime", "hyper", "hyper-util", "mockall", diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ dashmap = "6.1.0" hickory-resolver = "0.24.1" http-body-util = "0.1.2" +humantime = "2.1.0" hyper = { version = "1.5.0", features = ["full"] } hyper-util = { version = "0.1", features = ["full"] } notify = "7.0.0" diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -12,14 +12,15 @@ Roughly in the order I intend to work on: -- Temporarily allow unknown keys for SSH local port forwarding -- Allow user-provided key fingerprints for a tunnel -- Option to garbage-collect TCP/WebSocket connections +- Figure out way to allow proxy jump without valid fingerprint, while avoiding security concerns - Use env_logger -- API-based password authentication +- Option to garbage-collect TCP/WebSocket connections - Admin interface through SSH +- API-based password authentication - Documentation - Improve technical debts + - TO-DOs + - Allow user-provided key fingerprints for tunnel authentication - And more ## Features diff --git a/src/addressing.rs b/src/addressing.rs --- a/src/addressing.rs +++ b/src/addressing.rs @@ -43,7 +43,7 @@ requested_address: &str, fingerprint: &str, ) -> bool { - // TO-DO: Allow verifying whole subdomain chain for a matching fingerprint + // TO-DO: Allow verifying whole subdomain chain for a matching fingerprint (via config) if let Ok(lookup) = self .0 .txt_lookup(format!("{}.{}.", txt_record_prefix, requested_address)) diff --git a/src/config.rs b/src/config.rs --- a/src/config.rs +++ b/src/config.rs @@ -19,7 +19,8 @@ pub struct ApplicationConfig { pub domain: String, pub domain_redirect: String, - pub public_keys_directory: PathBuf, + pub user_keys_directory: PathBuf, + pub admin_keys_directory: PathBuf, pub certificates_directory: PathBuf, pub private_key_file: PathBuf, pub listen_address: String, @@ -34,6 +35,7 @@ pub txt_record_prefix: String, pub allow_provided_subdomains: bool, pub allow_requested_ports: bool, + pub idle_connection_timeout: Duration, pub random_subdomain_seed: Option, pub request_timeout: Duration, } diff --git a/src/fingerprints.rs b/src/fingerprints.rs --- a/src/fingerprints.rs +++ b/src/fingerprints.rs @@ -5,52 +5,98 @@ time::Duration, }; -use crate::directory::watch_directory; +use crate::{directory::watch_directory, ssh::Authentication}; use notify::RecommendedWatcher; -use russh_keys::{key::PublicKey, load_public_key}; +use russh_keys::load_public_key; use tokio::{fs::read_dir, sync::oneshot, task::JoinHandle}; #[derive(Debug)] pub(crate) struct FingerprintsValidator { - pub(crate) fingerprints: Arc>>, + user_fingerprints: Arc>>, + admin_fingerprints: Arc>>, join_handle: JoinHandle<()>, - _watcher: RecommendedWatcher, + _watchers: [RecommendedWatcher; 2], } impl FingerprintsValidator { - pub(crate) async fn watch(directory: PathBuf) -> anyhow::Result { - let fingerprints = Arc::new(RwLock::new(HashSet::new())); - let (watcher, mut pubkeys_rx) = watch_directory::(directory.as_path())?; - pubkeys_rx.mark_changed(); - let fingerprints_clone = Arc::clone(&fingerprints); + pub(crate) async fn watch( + user_keys_directory: PathBuf, + admin_keys_directory: PathBuf, + ) -> anyhow::Result { + let user_fingerprints = Arc::new(RwLock::new(HashSet::new())); + let admin_fingerprints = Arc::new(RwLock::new(HashSet::new())); + let (user_watcher, mut user_rx) = + watch_directory::(user_keys_directory.as_path())?; + let (admin_watcher, mut admin_rx) = + watch_directory::(admin_keys_directory.as_path())?; + user_rx.mark_changed(); + let user_fingerprints_clone = Arc::clone(&user_fingerprints); + let admin_fingerprints_clone = Arc::clone(&admin_fingerprints); let (init_tx, init_rx) = oneshot::channel::<()>(); let join_handle = tokio::spawn(async move { let mut init_tx = Some(init_tx); - while pubkeys_rx.changed().await.is_ok() { - let mut set = HashSet::new(); - match read_dir(directory.as_path()).await { + loop { + if async { + tokio::select! { + change = user_rx.changed() => change.is_err(), + change = admin_rx.changed() => change.is_err(), + } + } + .await + { + break; + } + let mut user_set = HashSet::new(); + match read_dir(user_keys_directory.as_path()).await { Ok(mut read_dir) => { while let Ok(Some(entry)) = read_dir.next_entry().await { // TO-DO: Load multiple keys from single file match load_public_key(entry.path()) { Ok(data) => { - set.insert(data.fingerprint()); + user_set.insert(data.fingerprint()); } - Err(e) => { + Err(err) => { eprintln!( "Unable to load public key in {:?}: {}", entry.file_name(), - e + err ); } } } - *fingerprints_clone.write().unwrap() = set; + *user_fingerprints_clone.write().unwrap() = user_set; } Err(err) => { eprintln!( - "Unable to read public keys directory {:?}: {}", - &directory, err + "Unable to read user keys directory {:?}: {}", + &user_keys_directory, err + ); + } + } + let mut admin_set = HashSet::new(); + match read_dir(admin_keys_directory.as_path()).await { + Ok(mut read_dir) => { + while let Ok(Some(entry)) = read_dir.next_entry().await { + // TO-DO: Load multiple keys from single file + match load_public_key(entry.path()) { + Ok(data) => { + admin_set.insert(data.fingerprint()); + } + Err(err) => { + eprintln!( + "Unable to load public key in {:?}: {}", + entry.file_name(), + err + ); + } + } + } + *admin_fingerprints_clone.write().unwrap() = admin_set; + } + Err(err) => { + eprintln!( + "Unable to read admin keys directory {:?}: {}", + &admin_keys_directory, err ); } } @@ -61,17 +107,26 @@ }); init_rx.await.unwrap(); Ok(FingerprintsValidator { - fingerprints, + user_fingerprints, + admin_fingerprints, join_handle, - _watcher: watcher, + _watchers: [user_watcher, admin_watcher], }) } - pub(crate) fn is_key_allowed(&self, key: &PublicKey) -> bool { - self.fingerprints + pub(crate) fn authenticate_fingerprint(&self, fingerprint: &str) -> Authentication { + if self + .admin_fingerprints .read() .unwrap() - .contains(&key.fingerprint()) + .contains(fingerprint) + { + Authentication::Admin + } else if self.user_fingerprints.read().unwrap().contains(fingerprint) { + Authentication::User + } else { + Authentication::None + } } } @@ -85,11 +140,20 @@ mod fingerprints_validator_tests { use std::sync::LazyLock; + use crate::ssh::Authentication; + use super::FingerprintsValidator; use russh_keys::{key::PublicKey, parse_public_key_base64}; - static PUBLIC_KEYS_DIRECTORY: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/public_keys"); + static USER_KEYS_DIRECTORY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/user_keys"); + static ADMIN_KEYS_DIRECTORY: &str = + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/admin_keys"); + static ADMIN_KEY: LazyLock = LazyLock::new(|| { + parse_public_key_base64( + "AAAAC3NzaC1lZDI1NTE5AAAAIDpmDGLbC68yM87r+fD/aoEimDdnzZtmnZXCnxkIGHMq", + ) + .unwrap() + }); static KEY_ONE: LazyLock = LazyLock::new(|| { parse_public_key_base64( "AAAAC3NzaC1lZDI1NTE5AAAAIMYVfXHTqf3/0W8ZQ/I8zmMirvmosV78n1qtYgVQX58W", @@ -109,19 +173,28 @@ }); #[tokio::test] - async fn allows_known_keys() { - let validator = FingerprintsValidator::watch(PUBLIC_KEYS_DIRECTORY.parse().unwrap()) - .await - .unwrap(); - assert!(validator.is_key_allowed(&KEY_ONE)); - assert!(validator.is_key_allowed(&KEY_TWO)); - } - - #[tokio::test] - async fn forbids_unknown_keys() { - let validator = FingerprintsValidator::watch(PUBLIC_KEYS_DIRECTORY.parse().unwrap()) - .await - .unwrap(); - assert!(!validator.is_key_allowed(&UNKNOWN_KEY)); + async fn authenticates_user_keys() { + let validator = FingerprintsValidator::watch( + USER_KEYS_DIRECTORY.parse().unwrap(), + ADMIN_KEYS_DIRECTORY.parse().unwrap(), + ) + .await + .unwrap(); + assert_eq!( + validator.authenticate_fingerprint(&ADMIN_KEY.fingerprint()), + Authentication::Admin + ); + assert_eq!( + validator.authenticate_fingerprint(&KEY_ONE.fingerprint()), + Authentication::User + ); + assert_eq!( + validator.authenticate_fingerprint(&KEY_TWO.fingerprint()), + Authentication::User + ); + assert_eq!( + validator.authenticate_fingerprint(&UNKNOWN_KEY.fingerprint()), + Authentication::None + ); } } diff --git a/src/lib.rs b/src/lib.rs --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,7 @@ -use std::sync::{Arc, RwLock}; +use std::{ + sync::{Arc, RwLock}, + time::Duration, +}; use anyhow::Context; use http::DomainRedirect; @@ -10,7 +13,7 @@ use rustls::ServerConfig; use rustls_acme::is_tls_alpn_challenge; use tcp::TcpHandler; -use tokio::{fs, io::AsyncWriteExt, net::TcpListener}; +use tokio::{fs, io::AsyncWriteExt, net::TcpListener, sync::oneshot}; use tokio_rustls::LazyConfigAcceptor; use crate::{ @@ -49,6 +52,7 @@ pub(crate) http_port: u16, pub(crate) https_port: u16, pub(crate) ssh_port: u16, + pub(crate) idle_connection_timeout: Duration, } pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { @@ -78,9 +82,12 @@ }; let fingerprints = Arc::new( - FingerprintsValidator::watch(config.public_keys_directory.clone()) - .await - .with_context(|| "Error setting up public keys watcher")?, + FingerprintsValidator::watch( + config.user_keys_directory.clone(), + config.admin_keys_directory.clone(), + ) + .await + .with_context(|| "Error setting up public keys watcher")?, ); let alpn_resolver: Box = match config.acme_contact_email { Some(contact) => { @@ -233,9 +240,9 @@ }); let ssh_config = Arc::new(Config { - inactivity_timeout: Some(std::time::Duration::from_secs(3_600)), - auth_rejection_time: std::time::Duration::from_secs(1), - auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)), + inactivity_timeout: Some(Duration::from_secs(3_600)), + auth_rejection_time: Duration::min(config.idle_connection_timeout, Duration::from_secs(2)), + auth_rejection_time_initial: Some(Duration::from_secs(0)), keys: vec![key], ..Default::default() }); @@ -245,11 +252,12 @@ tcp: tcp_connections, fingerprints_validator: fingerprints, address_delegator: addressing, - tcp_handler: tcp_handler, + tcp_handler, domain: config.domain, http_port: config.http_port, https_port: config.https_port, ssh_port: config.ssh_port, + idle_connection_timeout: config.idle_connection_timeout, }); let ssh_listener = TcpListener::bind((config.listen_address.clone(), config.ssh_port)) .await @@ -262,7 +270,8 @@ }; debug_assert_eq!(stream.peer_addr().ok(), Some(address)); let config = Arc::clone(&ssh_config); - let handler = sandhole.new_client(Some(address)); + let (tx, rx) = oneshot::channel::<()>(); + let handler = sandhole.new_client(Some(address), tx); tokio::spawn(async move { let session = match russh::server::run_stream(config, stream, handler).await { Ok(session) => session, @@ -271,12 +280,14 @@ return; } }; - match session.await { - Ok(_) => (), - Err(_) => { - // Connection closed with error - return; + tokio::select! { + result = session => { + if let Err(_) = result { + // Connection closed with error + return; + } } + _ = rx => return, } }); } diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ -use std::{path::PathBuf, time::Duration}; +use std::path::PathBuf; use clap::{command, Parser, ValueEnum}; +use humantime::Duration; use sandhole::{ config::{ApplicationConfig, BindHostnames as BHConfig, RandomSubdomainSeed as RSSConfig}, entrypoint, @@ -61,10 +62,15 @@ #[arg(long, default_value_t = String::from(env!("CARGO_PKG_REPOSITORY")))] domain_redirect: String, - /// Directory containing authorized public keys. + /// Directory containing public keys of authorized users. /// Each file must contain exactly one key. - #[arg(long, default_value_os = "./deploy/public_keys/")] - public_keys_directory: PathBuf, + #[arg(long, default_value_os = "./deploy/user_keys/")] + user_keys_directory: PathBuf, + + /// Directory containing public keys of admin users. + /// Each file must contain exactly one key. + #[arg(long, default_value_os = "./deploy/admin_keys/")] + admin_keys_directory: PathBuf, /// Directory containing SSL certificates and keys. /// Each sub-directory inside of this one must contain a certificate chain in a @@ -132,6 +138,12 @@ #[arg(long, default_value_t = false)] allow_requested_ports: bool, + /// Grace period for dangling/unauthenticated SSH connections before they are forcefully disconnected. + /// + /// A low value may cause valid proxy/tunnel connections to be erroneously removed. + #[arg(long, default_value = "5s")] + idle_connection_timeout: Duration, + /// Which value to seed with when generating random subdomains, for determinism. This allows binding to the same /// random address until Sandhole is restarted. /// @@ -141,9 +153,9 @@ #[arg(long, value_enum)] random_subdomain_seed: Option, - /// Time in seconds until an outgoing HTTP request is automatically canceled. - #[arg(long, default_value_t = 10)] - request_timeout: u64, + /// Time until an outgoing HTTP request is automatically canceled. + #[arg(long, default_value = "10s")] + request_timeout: Duration, } #[tokio::main] @@ -152,7 +164,8 @@ let config = ApplicationConfig { domain: args.domain, domain_redirect: args.domain_redirect, - public_keys_directory: args.public_keys_directory, + user_keys_directory: args.user_keys_directory, + admin_keys_directory: args.admin_keys_directory, certificates_directory: args.certificates_directory, private_key_file: args.private_key_file, listen_address: args.listen_address, @@ -167,8 +180,9 @@ txt_record_prefix: args.txt_record_prefix, allow_provided_subdomains: args.allow_provided_subdomains, allow_requested_ports: args.allow_requested_ports, + idle_connection_timeout: args.idle_connection_timeout.into(), random_subdomain_seed: args.random_subdomain_seed.map(Into::into), - request_timeout: Duration::from_secs(args.request_timeout), + request_timeout: args.request_timeout.into(), }; entrypoint(config).await } diff --git a/src/ssh.rs b/src/ssh.rs --- a/src/ssh.rs +++ b/src/ssh.rs @@ -19,7 +19,8 @@ use russh_keys::key::PublicKey; use tokio::{ io::{copy_bidirectional, AsyncWriteExt}, - sync::mpsc, + sync::{mpsc, oneshot, Mutex}, + time::sleep, }; #[derive(Clone)] @@ -68,34 +69,58 @@ } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Authentication { + /// Not authenticated. + None, + /// Authenticated as a proxy/tunneling user. + Proxy, + /// Authenticated as a valid user. + User, + /// Authenticated as an admin. + Admin, +} + // TO-DO: Optimize memory usage pub(crate) struct ServerHandler { - pub(crate) peer: SocketAddr, - pub(crate) user: Option, - pub(crate) key_fingerprint: Option, - pub(crate) tx: mpsc::Sender>, - pub(crate) rx: Option>>, - pub(crate) ssh_hosts: HashSet, - pub(crate) http_hosts: HashSet, - pub(crate) tcp_ports: HashSet, - pub(crate) host_addressing: HashMap<(String, u32), String>, - pub(crate) port_addressing: HashMap<(String, u32), u16>, - pub(crate) address_delegator: Arc>, - pub(crate) server: Arc, + cancellation_tx: Option>, + peer: SocketAddr, + user: Option, + key_fingerprint: Option, + authentication: Arc>, + tx: mpsc::Sender>, + rx: Option>>, + ssh_hosts: HashSet, + http_hosts: HashSet, + tcp_ports: HashSet, + host_addressing: HashMap<(String, u32), String>, + port_addressing: HashMap<(String, u32), u16>, + address_delegator: Arc>, + server: Arc, } pub(crate) trait Server { - fn new_client(&mut self, peer_address: Option) -> ServerHandler; + fn new_client( + &mut self, + peer_address: Option, + cancellation_tx: oneshot::Sender<()>, + ) -> ServerHandler; } impl Server for Arc { - fn new_client(&mut self, peer_address: Option) -> ServerHandler { - let (tx, rx) = mpsc::channel(32); + fn new_client( + &mut self, + peer_address: Option, + cancellation_tx: oneshot::Sender<()>, + ) -> ServerHandler { + let (tx, rx) = mpsc::channel(64); let peer_address = peer_address.unwrap(); ServerHandler { + cancellation_tx: Some(cancellation_tx), peer: peer_address, user: None, key_fingerprint: None, + authentication: Arc::new(Mutex::new(Authentication::None)), tx, rx: Some(rx), ssh_hosts: HashSet::new(), @@ -121,6 +146,9 @@ let Some(mut rx) = self.rx.take() else { return Err(russh::Error::Disconnect); }; + if *self.authentication.lock().await == Authentication::None { + return Err(russh::Error::Disconnect); + } let mut stream = channel.into_stream(); tokio::spawn(async move { while let Some(message) = rx.recv().await { @@ -143,18 +171,35 @@ user: &str, public_key: &PublicKey, ) -> Result { - if self + let fingerprint = public_key.fingerprint(); + self.user = Some(user.to_string()); + self.key_fingerprint = Some(fingerprint); + let authentication = self .server .fingerprints_validator - .is_key_allowed(public_key) - { - self.key_fingerprint = Some(public_key.fingerprint()); - self.user = Some(user.to_string()); - Ok(Auth::Accept) - } else { - Ok(Auth::Reject { - proceed_with_methods: None, - }) + .authenticate_fingerprint(self.key_fingerprint.as_ref().unwrap()); + let mut authentication_guard = self.authentication.lock().await; + *authentication_guard = authentication; + drop(authentication_guard); + match authentication { + Authentication::None => { + // Start timer for user to do local port forwarding. + // Otherwise, the connection will be canceled upon expiration + let authentication = Arc::clone(&self.authentication); + let Some(cancellation_tx) = self.cancellation_tx.take() else { + return Err(russh::Error::Disconnect); + }; + let timeout = self.server.idle_connection_timeout; + tokio::spawn(async move { + sleep(timeout).await; + if *authentication.lock().await == Authentication::None { + let _ = cancellation_tx.send(()); + } + }); + Ok(Auth::Accept) + } + Authentication::Proxy => unreachable!(), + Authentication::User | Authentication::Admin => Ok(Auth::Accept), } } @@ -164,9 +209,31 @@ data: &[u8], _session: &mut Session, ) -> Result<(), Self::Error> { + match *self.authentication.lock().await { + Authentication::None => return Err(russh::Error::Disconnect), + Authentication::Proxy | Authentication::User | Authentication::Admin => (), + } // Sending Ctrl+C ends the session and disconnects the client if data == [3] { return Err(russh::Error::Disconnect); + } + Ok(()) + } + + // TO-DO: Admin interface + async fn pty_request( + &mut self, + _channel: ChannelId, + _term: &str, + _col_width: u32, + _row_height: u32, + _pix_width: u32, + _pix_height: u32, + _modes: &[(russh::Pty, u32)], + _session: &mut Session, + ) -> Result<(), Self::Error> { + if *self.authentication.lock().await != Authentication::Admin { + return Ok(()); } Ok(()) } @@ -177,6 +244,11 @@ port: &mut u32, session: &mut Session, ) -> Result { + // Only allow remote forwarding for authorized keys + match *self.authentication.lock().await { + Authentication::None | Authentication::Proxy => return Err(russh::Error::Disconnect), + Authentication::User | Authentication::Admin => (), + } let address = address.to_string(); let handle = session.handle(); match *port { @@ -328,7 +400,6 @@ port: u32, _session: &mut Session, ) -> Result { - // TO-DO: Handle more than HTTP match port { 22 => { if let Some(assigned_host) = @@ -366,7 +437,7 @@ } } - // TO-DO: Add proper authentication for forwarding + // TO-DO: Add user-defined authentication mechanism for forwarding (tunneling) async fn channel_open_direct_tcpip( &mut self, channel: Channel, @@ -383,6 +454,11 @@ .tunneling_channel(originator_address, originator_port as u16) .await { + let mut authentication = self.authentication.lock().await; + if *authentication == Authentication::None { + *authentication = Authentication::Proxy; + }; + drop(authentication); tokio::spawn(async move { let mut stream = channel.into_stream(); let _ = copy_bidirectional(&mut stream, io.inner_mut()).await; @@ -404,6 +480,11 @@ .tunneling_channel(originator_address, originator_port as u16) .await { + let mut authentication = self.authentication.lock().await; + if *authentication == Authentication::None { + *authentication = Authentication::Proxy; + }; + drop(authentication); tokio::spawn(async move { let mut stream = channel.into_stream(); let _ = copy_bidirectional(&mut stream, io.inner_mut()).await; @@ -424,6 +505,11 @@ .tunneling_channel(originator_address, originator_port as u16) .await { + let mut authentication = self.authentication.lock().await; + if *authentication == Authentication::None { + *authentication = Authentication::Proxy; + }; + drop(authentication); tokio::spawn(async move { let mut stream = channel.into_stream(); let _ = copy_bidirectional(&mut stream, io.inner_mut()).await; diff --git a/tests/https_bind_all_hostnames.rs b/tests/https_bind_all_hostnames.rs --- a/tests/https_bind_all_hostnames.rs +++ b/tests/https_bind_all_hostnames.rs @@ -34,8 +34,8 @@ let config = ApplicationConfig { domain: "foobar.tld".into(), domain_redirect: "https://tokio.rs/".into(), - public_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/public_keys") - .into(), + user_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/user_keys").into(), + admin_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/admin_keys").into(), certificates_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/certificates") .into(), private_key_file: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/server_keys/ssh").into(), @@ -51,6 +51,7 @@ allow_provided_subdomains: false, allow_requested_ports: false, random_subdomain_seed: None, + idle_connection_timeout: Duration::from_secs(1), txt_record_prefix: "_sandhole".into(), request_timeout: Duration::from_secs(5), }; diff --git a/tests/https_force_random_subdomains.rs b/tests/https_force_random_subdomains.rs --- a/tests/https_force_random_subdomains.rs +++ b/tests/https_force_random_subdomains.rs @@ -34,8 +34,8 @@ let config = ApplicationConfig { domain: "foobar.tld".into(), domain_redirect: "https://tokio.rs/".into(), - public_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/public_keys") - .into(), + user_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/user_keys").into(), + admin_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/admin_keys").into(), certificates_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/certificates") .into(), private_key_file: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/server_keys/ssh").into(), @@ -51,6 +51,7 @@ allow_provided_subdomains: false, allow_requested_ports: false, random_subdomain_seed: None, + idle_connection_timeout: Duration::from_secs(1), txt_record_prefix: "_sandhole".into(), request_timeout: Duration::from_secs(5), }; diff --git a/tests/prevent_unauthorized_actions.rs b/tests/prevent_unauthorized_actions.rs new file mode 100644 --- /dev/null +++ b/tests/prevent_unauthorized_actions.rs @@ -0,0 +1,115 @@ +use std::{sync::Arc, time::Duration}; + +use async_trait::async_trait; +use russh::{ + client::{Msg, Session}, + Channel, +}; +use russh_keys::{key, load_secret_key}; +use sandhole::{ + config::{ApplicationConfig, BindHostnames}, + entrypoint, +}; +use tokio::{ + io::AsyncReadExt, + net::TcpStream, + time::{sleep, timeout}, +}; + +// TO-DO: Write this test +/// In order for tunneling to work, Sandhole must allow any public key to connect. +/// However, unauthorized users should have much more restricted access, only being allowed +/// to request local port forwarding (as of this version). +/// +/// This test ensures that any other actions result in an error with a disconnect. +#[tokio::test(flavor = "multi_thread")] +#[ignore] +async fn prevent_unauthorized_actions() { + // 1. Initialize Sandhole + let config = ApplicationConfig { + domain: "foobar.tld".into(), + domain_redirect: "https://tokio.rs/".into(), + user_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/user_keys").into(), + admin_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/admin_keys").into(), + certificates_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/certificates") + .into(), + private_key_file: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/server_keys/ssh").into(), + listen_address: "127.0.0.1".into(), + ssh_port: 18022, + http_port: 18080, + https_port: 18443, + force_https: false, + acme_contact_email: None, + acme_cache_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/acme_cache").into(), + acme_use_staging: true, + bind_hostnames: BindHostnames::None, + allow_provided_subdomains: false, + allow_requested_ports: true, + random_subdomain_seed: None, + idle_connection_timeout: Duration::from_secs(1), + txt_record_prefix: "_sandhole".into(), + request_timeout: Duration::from_secs(5), + }; + tokio::spawn(async move { entrypoint(config).await }); + if let Err(_) = timeout(Duration::from_secs(5), async { + while let Err(_) = TcpStream::connect("127.0.0.1:18022").await { + sleep(Duration::from_millis(100)).await; + } + }) + .await + { + panic!("Timeout waiting for Sandhole to start.") + }; + + // 2. Start SSH client that will be proxied + let key = load_secret_key( + concat!(env!("CARGO_MANIFEST_DIR"), "/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", Arc::new(key)) + .await + .expect("SSH authentication failed")); + session + .tcpip_forward("my.hostname", 12345) + .await + .expect("tcpip_forward failed"); + + // 3. Connect to the TCP port of our proxy + let mut tcp_stream = TcpStream::connect("127.0.0.1:12345") + .await + .expect("TCP connection failed"); + let mut buf = String::with_capacity(13); + tcp_stream.read_to_string(&mut buf).await.unwrap(); + assert_eq!(buf, "Hello, world!"); +} + +struct SshClient; + +#[async_trait] +impl russh::client::Handler for SshClient { + type Error = anyhow::Error; + + async fn check_server_key(&mut self, _key: &key::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> { + channel.data(&b"Hello, world!"[..]).await.unwrap(); + channel.eof().await.unwrap(); + Ok(()) + } +} diff --git a/tests/ssh_proxy_jump.rs b/tests/ssh_proxy_jump.rs --- a/tests/ssh_proxy_jump.rs +++ b/tests/ssh_proxy_jump.rs @@ -27,8 +27,8 @@ let config = ApplicationConfig { domain: "foobar.tld".into(), domain_redirect: "https://tokio.rs/".into(), - public_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/public_keys") - .into(), + user_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/user_keys").into(), + admin_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/admin_keys").into(), certificates_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/certificates") .into(), private_key_file: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/server_keys/ssh").into(), @@ -44,6 +44,7 @@ allow_provided_subdomains: false, allow_requested_ports: false, random_subdomain_seed: None, + idle_connection_timeout: Duration::from_secs(2), txt_record_prefix: "_sandhole".into(), request_timeout: Duration::from_secs(5), }; @@ -78,11 +79,7 @@ .expect("tcpip_forward failed"); // 3. Connect to the SSH port of our proxy - let key = load_secret_key( - concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/private_keys/key2"), - None, - ) - .expect("Missing file key2"); + let key = russh_keys::key::KeyPair::generate_ed25519(); let ssh_client = ProxyClient; let mut session = client::connect(Default::default(), "127.0.0.1:18022", ssh_client) .await diff --git a/tests/tcp_allow_requested_ports.rs b/tests/tcp_allow_requested_ports.rs --- a/tests/tcp_allow_requested_ports.rs +++ b/tests/tcp_allow_requested_ports.rs @@ -17,13 +17,13 @@ }; #[tokio::test(flavor = "multi_thread")] -async fn https_force_random_subdomains() { +async fn tcp_allow_requested_ports() { // 1. Initialize Sandhole let config = ApplicationConfig { domain: "foobar.tld".into(), domain_redirect: "https://tokio.rs/".into(), - public_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/public_keys") - .into(), + user_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/user_keys").into(), + admin_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/admin_keys").into(), certificates_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/certificates") .into(), private_key_file: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/server_keys/ssh").into(), @@ -39,6 +39,7 @@ allow_provided_subdomains: false, allow_requested_ports: true, random_subdomain_seed: None, + idle_connection_timeout: Duration::from_secs(1), txt_record_prefix: "_sandhole".into(), request_timeout: Duration::from_secs(5), }; diff --git a/tests/data/admin_keys/admin.pub b/tests/data/admin_keys/admin.pub new file mode 100644 --- /dev/null +++ b/tests/data/admin_keys/admin.pub @@ -0,0 +1,1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDpmDGLbC68yM87r+fD/aoEimDdnzZtmnZXCnxkIGHMq admin diff --git a/tests/data/private_keys/admin b/tests/data/private_keys/admin new file mode 100644 --- /dev/null +++ b/tests/data/private_keys/admin @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACA6Zgxi2wuvMjPO6/nw/2qBIpg3Z82bZp2Vwp8ZCBhzKgAAAIhFXi0TRV4t +EwAAAAtzc2gtZWQyNTUxOQAAACA6Zgxi2wuvMjPO6/nw/2qBIpg3Z82bZp2Vwp8ZCBhzKg +AAAEAD9fkpKRSK+isx63Thv+5luWl4N1c+StKmIn1+07zwwDpmDGLbC68yM87r+fD/aoEi +mDdnzZtmnZXCnxkIGHMqAAAABWFkbWlu +-----END OPENSSH PRIVATE KEY----- diff --git a/tests/data/public_keys/key1.pub b/tests/data/public_keys/key1.pub deleted file mode 100644 --- a/tests/data/public_keys/key1.pub +++ /dev/null @@ -1,1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMYVfXHTqf3/0W8ZQ/I8zmMirvmosV78n1qtYgVQX58W key1 diff --git a/tests/data/public_keys/key2.pub b/tests/data/public_keys/key2.pub deleted file mode 100644 --- a/tests/data/public_keys/key2.pub +++ /dev/null @@ -1,1 +0,0 @@ -ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCUdw1f/va/ax8L/5qoZw37+76psjybsY7qNJMxOhwqKQ6fKiLu2xv+uFQxdEbNitXbcC8zZ2m98XzEPlNoY3DTqw5RAt2qZQMMXFLzDNHCpY6xT1DxLFTYxczXj9Xk4Ms7/RQP6pxLV5PIVc06HXBThCzcLMDdnl9n0jEWu1CwSGtsc87/Gvbnr3QrfrnK40IS7c5SIfbI5yN7pfnCEkRf637EGzc11Tq4e2/ujweETZ1C+KcJZapVVHTvFfITyOqLeqrgXgsMQUML48SfDUl/RsY4nk6aFKwK7f0oGzykqLTX0YHS1wxLOnPSkK33ohvtjvcUzA/eAmjUiQquJQ7DW6RPvW57lozzIxwFvO4O/j398r3W1de3R7Q3rmAwKbujFSJlZb4OvS1ZLS8md8TwCO1xwE+4aY3xvsmeHpfBcEjhTmEYEEY630hbiMgHsbH1M7uAZkbXUgw7R6cLPCndc4GiDOLN/bkKwa55evbOS1J1cD4pi5lUSnzZzk9lYrU= key2 diff --git a/tests/data/user_keys/admin.pub b/tests/data/user_keys/admin.pub new file mode 100644 --- /dev/null +++ b/tests/data/user_keys/admin.pub @@ -0,0 +1,1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDpmDGLbC68yM87r+fD/aoEimDdnzZtmnZXCnxkIGHMq admin diff --git a/tests/data/user_keys/key1.pub b/tests/data/user_keys/key1.pub new file mode 100644 --- /dev/null +++ b/tests/data/user_keys/key1.pub @@ -0,0 +1,1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMYVfXHTqf3/0W8ZQ/I8zmMirvmosV78n1qtYgVQX58W key1 diff --git a/tests/data/user_keys/key2.pub b/tests/data/user_keys/key2.pub new file mode 100644 --- /dev/null +++ b/tests/data/user_keys/key2.pub @@ -0,0 +1,1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCUdw1f/va/ax8L/5qoZw37+76psjybsY7qNJMxOhwqKQ6fKiLu2xv+uFQxdEbNitXbcC8zZ2m98XzEPlNoY3DTqw5RAt2qZQMMXFLzDNHCpY6xT1DxLFTYxczXj9Xk4Ms7/RQP6pxLV5PIVc06HXBThCzcLMDdnl9n0jEWu1CwSGtsc87/Gvbnr3QrfrnK40IS7c5SIfbI5yN7pfnCEkRf637EGzc11Tq4e2/ujweETZ1C+KcJZapVVHTvFfITyOqLeqrgXgsMQUML48SfDUl/RsY4nk6aFKwK7f0oGzykqLTX0YHS1wxLOnPSkK33ohvtjvcUzA/eAmjUiQquJQ7DW6RPvW57lozzIxwFvO4O/j398r3W1de3R7Q3rmAwKbujFSJlZb4OvS1ZLS8md8TwCO1xwE+4aY3xvsmeHpfBcEjhTmEYEEY630hbiMgHsbH1M7uAZkbXUgw7R6cLPCndc4GiDOLN/bkKwa55evbOS1J1cD4pi5lUSnzZzk9lYrU= key2