diff --git a/CHANGELOG.md b/CHANGELOG.md index e56f0b4..67eb73d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,15 @@ ## Unreleased +### Added + +- Add `--disable-http` CLI flag. +- Add `--disable-tcp` CLI flag. + ### Changed - Separate TCP and alias logic. -- Remove aliasing tabs from admin interface when using `--disable-aliasing`. +- Remove inaccesible tabs from admin interface when using one or more of the `--disable-*` flags. ## 0.3.0 (2024-12-28) diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 8bc1a1d..6ee106f 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -17,5 +17,5 @@ # Reference -- [Frequently Asked Questions](./faq.md) +- [Frequently asked questions](./faq.md) - [CLI](./cli.md) diff --git a/book/src/cli.md b/book/src/cli.md index b3e1672..74e2eb5 100644 --- a/book/src/cli.md +++ b/book/src/cli.md @@ -159,6 +159,13 @@ Expose HTTP/SSH/TCP services through SSH port forwarding. --allow-requested-ports Allow user-requested ports. By default, ports are always random + --disable-http + Disable all HTTP tunneling. By default, this is enabled globally + + --disable-tcp + Disable all TCP port tunneling except HTTP. By default, this is + enabled globally + --disable-aliasing Disable all aliasing (i.e. local forwarding). By default, this is enabled globally diff --git a/book/src/faq.md b/book/src/faq.md index 03d4998..f06c24c 100644 --- a/book/src/faq.md +++ b/book/src/faq.md @@ -1,4 +1,4 @@ -# Frequently Asked Questions +# Frequently asked questions ## How do I expose my service on multiple URLs (for example, `website.com` and `www.website.com`)? @@ -12,7 +12,7 @@ See ["Advanced Uses"](./advanced_uses.md#custom-domains) on how to add custom do Use `ssh -J sandhole.com:2222 mysshserver.com -p 2222` (replace the ports with Sandhole's SSH port if not using the default `2222`). -If you'd like to avoid typing out the proxy jump command every time, make sure to edit your SSH config file (usually `~/.ssh/config`) and add the following entry: +If you'd like to avoid typing out the proxy jump command every time, make sure to edit your SSH config file (usually `~/.ssh/config`) and add the following entry (changing the port where appropriate): ```ssh-config Host mysshserver.com @@ -31,3 +31,7 @@ With the `--force-https` [CLI flag](./cli.md). This is currently a global settin ## How do I enable Websockets? Websockets are always enabled for HTTP services. + +## How do I disable HTTP/TCP/aliasing? + +With the `--disable--http`, `--disable-tcp`, and `--disable-aliasing` [CLI flags](./cli.md) respectively. Note that you cannot disable all three at once. diff --git a/docker-compose-example/compose.yml b/docker-compose-example/compose.yml index 5c85a5e..6c55920 100644 --- a/docker-compose-example/compose.yml +++ b/docker-compose-example/compose.yml @@ -15,7 +15,7 @@ services: - ./letsencrypt:/letsencrypt:ro # Change this to where the CA certificates are on the host - /etc/ssl/certs:/etc/ssl/certs:ro - # See also: https://epiceric.github.io/sandhole/cli.html + # See also: https://sandhole.eric.dev.br/cli.html # Make sure to change at least --domain and --acme-contact-email below command: | --domain=server.com diff --git a/src/admin.rs b/src/admin.rs index 51918cc..ce0d416 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -55,6 +55,7 @@ impl io::Write for BufferedSender { } } +#[derive(Clone, Copy)] enum Tab { Http, Ssh, @@ -74,26 +75,24 @@ impl Tab { } struct TabData { - is_aliasing_disabled: bool, - current_tab: Tab, + tabs: Vec, + current: usize, } impl TabData { // Render the tabs at the top of the table fn render(&self, area: Rect, buf: &mut Buffer) { - Tabs::new(if self.is_aliasing_disabled { - vec![ - Line::from(" HTTP ".black().bg(Tab::Http.color())), - Line::from(" TCP ".black().bg(Tab::Tcp.color())), - ] - } else { - vec![ - Line::from(" HTTP ".black().bg(Tab::Http.color())), - Line::from(" SSH ".black().bg(Tab::Ssh.color())), - Line::from(" TCP ".black().bg(Tab::Tcp.color())), - Line::from(" Alias ".black().bg(Tab::Alias.color())), - ] - }) + Tabs::new( + self.tabs + .iter() + .map(|tab| match tab { + Tab::Http => Line::from(" HTTP ".black().bg(Tab::Http.color())), + Tab::Ssh => Line::from(" SSH ".black().bg(Tab::Ssh.color())), + Tab::Tcp => Line::from(" TCP ".black().bg(Tab::Tcp.color())), + Tab::Alias => Line::from(" Alias ".black().bg(Tab::Alias.color())), + }) + .collect::>(), + ) .select(self.index()) .highlight_style(Style::new().bold()) .divider(" ") @@ -101,53 +100,26 @@ impl TabData { } fn index(&self) -> usize { - if self.is_aliasing_disabled { - match self.current_tab { - Tab::Http => 0, - Tab::Tcp => 1, - Tab::Ssh | Tab::Alias => unreachable!(), - } - } else { - match self.current_tab { - Tab::Http => 0, - Tab::Ssh => 1, - Tab::Tcp => 2, - Tab::Alias => 3, - } - } + self.current + } + + fn current_tab(&self) -> Tab { + self.tabs[self.current] } fn previous(&mut self) { - if self.is_aliasing_disabled { - self.current_tab = match self.current_tab { - Tab::Http => Tab::Tcp, - Tab::Tcp => Tab::Http, - Tab::Ssh | Tab::Alias => unreachable!(), - } + if self.current == 0 { + self.current = self.tabs.len() - 1; } else { - self.current_tab = match self.current_tab { - Tab::Http => Tab::Alias, - Tab::Ssh => Tab::Http, - Tab::Tcp => Tab::Ssh, - Tab::Alias => Tab::Tcp, - }; + self.current -= 1; } } fn next(&mut self) { - if self.is_aliasing_disabled { - self.current_tab = match self.current_tab { - Tab::Http => Tab::Tcp, - Tab::Tcp => Tab::Http, - Tab::Ssh | Tab::Alias => unreachable!(), - } + if self.current == self.tabs.len() - 1 { + self.current = 0; } else { - self.current_tab = match self.current_tab { - Tab::Http => Tab::Ssh, - Tab::Ssh => Tab::Tcp, - Tab::Tcp => Tab::Alias, - Tab::Alias => Tab::Http, - }; + self.current += 1; } } } @@ -222,7 +194,7 @@ impl AdminState { self.tab.render(tabs_area, buf); self.render_system_data(system_data_area, buf); Block::bordered() - .border_style(Style::new().fg(self.tab.current_tab.color())) + .border_style(Style::new().fg(self.tab.current_tab().color())) .border_set(border::PROPORTIONAL_TALL) .render(inner_area, buf); self.render_tab(inner_area.inner(Margin::new(2, 1)), buf); @@ -347,8 +319,8 @@ impl AdminState { // Render the selected tab's contents fn render_tab(&mut self, area: Rect, buf: &mut Buffer) { - let color = self.tab.current_tab.color(); - let table = match self.tab.current_tab { + let color = self.tab.current_tab().color(); + let table = match self.tab.current_tab() { Tab::Http => { // Get data for HTTP let data = self.server.http_data.read().unwrap().clone(); @@ -570,15 +542,24 @@ impl AdminInterface { }; // Create a channel to listen for user-generated events let (change_notifier, mut subscriber) = watch::channel(()); - let is_aliasing_disabled = server.disable_aliasing; + let mut tabs = Vec::new(); + if !server.disable_http { + tabs.push(Tab::Http); + } + if !server.disable_aliasing { + tabs.push(Tab::Ssh); + } + if !server.disable_tcp { + tabs.push(Tab::Tcp); + } + if !server.disable_aliasing { + tabs.push(Tab::Alias); + } let interface = Arc::new(Mutex::new(AdminTerminal { terminal: Terminal::with_options(backend, options).unwrap(), state: AdminState { server, - tab: TabData { - is_aliasing_disabled, - current_tab: Tab::Http, - }, + tab: TabData { tabs, current: 0 }, is_pty: false, table_state: Default::default(), vertical_scroll: Default::default(), @@ -811,7 +792,7 @@ impl AdminInterface { // No prompt, select from table None => { if let Some(row) = interface.state.table_state.selected() { - let users: Option> = match interface.state.tab.current_tab { + let users: Option> = match interface.state.tab.current_tab() { Tab::Http => interface .state .server diff --git a/src/config.rs b/src/config.rs index 7dfcb6c..d04ddef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -232,6 +232,14 @@ pub struct ApplicationConfig { #[arg(long, default_value_t = false)] pub allow_requested_ports: bool, + /// Disable all HTTP tunneling. By default, this is enabled globally. + #[arg(long, default_value_t = false)] + pub disable_http: bool, + + /// Disable all TCP port tunneling except HTTP. By default, this is enabled globally. + #[arg(long, default_value_t = false)] + pub disable_tcp: bool, + /// Disable all aliasing (i.e. local forwarding). By default, this is enabled globally. #[arg(long, default_value_t = false)] pub disable_aliasing: bool, diff --git a/src/error.rs b/src/error.rs index 47f978f..1cc8bc9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; #[derive(thiserror::Error, Debug)] pub(crate) enum ServerError { + #[error("Invalid config: {0}")] + InvalidConfig(String), #[error("Missing Host header")] MissingHostHeader, #[error("Invalid Host header")] diff --git a/src/lib.rs b/src/lib.rs index 32cc8d9..852ef53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ use std::{ collections::{BTreeMap, HashMap}, + future, marker::PhantomData, net::{IpAddr, SocketAddr}, sync::{atomic::AtomicUsize, Arc, Mutex, RwLock}, @@ -156,7 +157,11 @@ pub(crate) struct SandholeServer { pub(crate) ssh_port: u16, // If true, allows users to select the TCP ports assigned by tcp_handler. pub(crate) force_random_ports: bool, - // If true, TCP aliasing is disabled, including SSH and all local forwarding connections. + // If true, HTTP is disabled. + pub(crate) disable_http: bool, + // If true, TCP is disabled for all ports except for HTTP. + pub(crate) disable_tcp: bool, + // If true, aliasing is disabled, including SSH and all local forwarding connections. pub(crate) disable_aliasing: bool, // How long until a login API request is timed out. pub(crate) authentication_request_timeout: Duration, @@ -178,6 +183,12 @@ impl SandholeServer { #[doc(hidden)] // Main entrypoint of the application. pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { + if config.disable_http && config.disable_tcp && config.disable_aliasing { + return Err(ServerError::InvalidConfig( + "One of HTTP, TCP, or aliasing must be enabled".into(), + ) + .into()); + } // Initialize crypto and credentials rustls::crypto::aws_lc_rs::default_provider() .install_default() @@ -456,6 +467,8 @@ pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { https_port: config.https_port, ssh_port: config.ssh_port, force_random_ports: !config.allow_requested_ports, + disable_http: config.disable_http, + disable_tcp: config.disable_tcp, disable_aliasing: config.disable_aliasing, authentication_request_timeout: config.authentication_request_timeout.into(), idle_connection_timeout: config.idle_connection_timeout.into(), @@ -467,164 +480,173 @@ pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { }); // HTTP handler - let http_listener = TcpListener::bind((listen_address, config.http_port)) - .await - .with_context(|| "Error listening to HTTP port")?; - info!( - "Listening for HTTP connections on port {}.", - config.http_port - ); - let http_proxy_data = Arc::new(ProxyData { - conn_manager: Arc::clone(&http_connections), - telemetry: Arc::clone(&telemetry), - domain_redirect: Arc::clone(&domain_redirect), - // Use TLS redirect if --force-https is set, otherwise allow HTTP. - protocol: if config.force_https { - Protocol::TlsRedirect { - from: config.http_port, - to: config.https_port, - } - } else { - Protocol::Http { - port: config.http_port, - } - }, - // Always use tunneling channels. - proxy_type: ProxyType::Tunneling, - http_request_timeout: config.http_request_timeout.into(), - websocket_timeout: config.tcp_connection_timeout.map(Into::into), - disable_http_logs: config.disable_http_logs, - _phantom_data: PhantomData, - }); - let mut join_handle_http = tokio::spawn(async move { - loop { - let proxy_data = Arc::clone(&http_proxy_data); - let (stream, address) = match http_listener.accept().await { - Ok((stream, address)) => (stream, address), - Err(err) => { - error!("Unable to accept HTTP connection: {}", err); - break; + let mut join_handle_http = if config.disable_http { + tokio::spawn(future::pending()) + } else { + let http_listener = TcpListener::bind((listen_address, config.http_port)) + .await + .with_context(|| "Error listening to HTTP port")?; + info!( + "Listening for HTTP connections on port {}.", + config.http_port + ); + let http_proxy_data = Arc::new(ProxyData { + conn_manager: Arc::clone(&http_connections), + telemetry: Arc::clone(&telemetry), + domain_redirect: Arc::clone(&domain_redirect), + // Use TLS redirect if --force-https is set, otherwise allow HTTP. + protocol: if config.force_https { + Protocol::TlsRedirect { + from: config.http_port, + to: config.https_port, } - }; - // Create a Hyper service and serve over the accepted TCP connection. - let service = service_fn(move |req: Request| { - proxy_handler(req, address, None, Arc::clone(&proxy_data)) - }); - let io = TokioIo::new(stream); - tokio::spawn(async move { - let server = auto::Builder::new(TokioExecutor::new()); - let conn = server.serve_connection_with_upgrades(io, service); - let _ = conn.await; - }); - } - }); + } else { + Protocol::Http { + port: config.http_port, + } + }, + // Always use tunneling channels. + proxy_type: ProxyType::Tunneling, + http_request_timeout: config.http_request_timeout.into(), + websocket_timeout: config.tcp_connection_timeout.map(Into::into), + disable_http_logs: config.disable_http_logs, + _phantom_data: PhantomData, + }); + tokio::spawn(async move { + loop { + let proxy_data = Arc::clone(&http_proxy_data); + let (stream, address) = match http_listener.accept().await { + Ok((stream, address)) => (stream, address), + Err(err) => { + error!("Unable to accept HTTP connection: {}", err); + break; + } + }; + // Create a Hyper service and serve over the accepted TCP connection. + let service = service_fn(move |req: Request| { + proxy_handler(req, address, None, Arc::clone(&proxy_data)) + }); + let io = TokioIo::new(stream); + tokio::spawn(async move { + let server = auto::Builder::new(TokioExecutor::new()); + let conn = server.serve_connection_with_upgrades(io, service); + let _ = conn.await; + }); + } + }) + }; // HTTPS handler (with optional SSH handling) - let https_listener = TcpListener::bind((listen_address, config.https_port)) - .await - .with_context(|| "Error listening to HTTPS port")?; - info!( - "Listening for HTTPS connections on port {}.", - config.https_port - ); - let certificates_clone = Arc::clone(&certificates); - let tls_server_config = Arc::new( - ServerConfig::builder() - .with_no_client_auth() - .with_cert_resolver(certificates), - ); - let https_proxy_data = Arc::new(ProxyData { - conn_manager: http_connections, - telemetry: Arc::clone(&telemetry), - domain_redirect: Arc::clone(&domain_redirect), - protocol: Protocol::Https { - port: config.https_port, - }, - // Always use tunneling channels. - proxy_type: ProxyType::Tunneling, - http_request_timeout: config.http_request_timeout.into(), - websocket_timeout: config.tcp_connection_timeout.map(Into::into), - disable_http_logs: config.disable_http_logs, - _phantom_data: PhantomData, - }); - let mut sandhole_clone = Arc::clone(&sandhole); - let ssh_config_clone = Arc::clone(&ssh_config); - let mut join_handle_https = tokio::spawn(async move { - loop { - let (stream, address) = match https_listener.accept().await { - Ok((stream, address)) => (stream, address), - Err(err) => { - error!("Unable to accept HTTPS connection: {}", err); - break; - } - }; - if config.connect_ssh_on_https_port { - // Check if this is an SSH-2.0 handshake. - let mut buf = [0u8; 8]; - if let Ok(n) = stream.peek(&mut buf).await { - if buf[..n].starts_with(b"SSH-2.0-") { - // Handle as an SSH connection instead of HTTPS. - handle_ssh_connection( - stream, - address, - &ssh_config_clone, - &mut sandhole_clone, - ) - .await; - continue; + let mut join_handle_https = if config.disable_http { + tokio::spawn(future::pending()) + } else { + let https_listener = TcpListener::bind((listen_address, config.https_port)) + .await + .with_context(|| "Error listening to HTTPS port")?; + info!( + "Listening for HTTPS connections on port {}.", + config.https_port + ); + let certificates_clone = Arc::clone(&certificates); + let tls_server_config = Arc::new( + ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(certificates), + ); + let https_proxy_data = Arc::new(ProxyData { + conn_manager: http_connections, + telemetry: Arc::clone(&telemetry), + domain_redirect: Arc::clone(&domain_redirect), + protocol: Protocol::Https { + port: config.https_port, + }, + // Always use tunneling channels. + proxy_type: ProxyType::Tunneling, + http_request_timeout: config.http_request_timeout.into(), + websocket_timeout: config.tcp_connection_timeout.map(Into::into), + disable_http_logs: config.disable_http_logs, + _phantom_data: PhantomData, + }); + let mut sandhole_clone = Arc::clone(&sandhole); + let ssh_config_clone = Arc::clone(&ssh_config); + tokio::spawn(async move { + loop { + let (stream, address) = match https_listener.accept().await { + Ok((stream, address)) => (stream, address), + Err(err) => { + error!("Unable to accept HTTPS connection: {}", err); + break; + } + }; + if config.connect_ssh_on_https_port { + // Check if this is an SSH-2.0 handshake. + let mut buf = [0u8; 8]; + if let Ok(n) = stream.peek(&mut buf).await { + if buf[..n].starts_with(b"SSH-2.0-") { + // Handle as an SSH connection instead of HTTPS. + handle_ssh_connection( + stream, + address, + &ssh_config_clone, + &mut sandhole_clone, + ) + .await; + continue; + } } } - } - let proxy_data = Arc::clone(&https_proxy_data); - let server_config = Arc::clone(&tls_server_config); - // Create a Hyper service and serve over the accepted TLS connection. - let service = service_fn(move |req: Request| { - proxy_handler(req, address, None, Arc::clone(&proxy_data)) - }); - // Create a ClientHello TLS stream from the TCP stream. - match LazyConfigAcceptor::new(Default::default(), stream).await { - Ok(handshake) => { - // Handle ALPN challenges with the ACME resolver. - if is_tls_alpn_challenge(&handshake.client_hello()) { - if let Some(challenge_config) = certificates_clone.challenge_rustls_config() - { + let proxy_data = Arc::clone(&https_proxy_data); + let server_config = Arc::clone(&tls_server_config); + // Create a Hyper service and serve over the accepted TLS connection. + let service = service_fn(move |req: Request| { + proxy_handler(req, address, None, Arc::clone(&proxy_data)) + }); + // Create a ClientHello TLS stream from the TCP stream. + match LazyConfigAcceptor::new(Default::default(), stream).await { + Ok(handshake) => { + // Handle ALPN challenges with the ACME resolver. + if is_tls_alpn_challenge(&handshake.client_hello()) { + if let Some(challenge_config) = + certificates_clone.challenge_rustls_config() + { + tokio::spawn(async move { + let mut tls = + handshake.into_stream(challenge_config).await.unwrap(); + tls.shutdown().await.unwrap(); + }); + } + } else { tokio::spawn(async move { - let mut tls = - handshake.into_stream(challenge_config).await.unwrap(); - tls.shutdown().await.unwrap(); + match handshake.into_stream(server_config).await { + Ok(stream) => { + let server = auto::Builder::new(TokioExecutor::new()); + let conn = server.serve_connection_with_upgrades( + TokioIo::new(stream), + service, + ); + let _ = conn.await; + } + Err(err) => { + warn!( + "Error establishing TLS connection with {}: {}", + address, err + ); + } + } }); } - } else { - tokio::spawn(async move { - match handshake.into_stream(server_config).await { - Ok(stream) => { - let server = auto::Builder::new(TokioExecutor::new()); - let conn = server.serve_connection_with_upgrades( - TokioIo::new(stream), - service, - ); - let _ = conn.await; - } - Err(err) => { - warn!( - "Error establishing TLS connection with {}: {}", - address, err - ); - } - } - }); } - } - Err(err) => { - warn!( - "Failed to establish TLS handshake with {}: {}", - address, err - ); - continue; + Err(err) => { + warn!( + "Failed to establish TLS handshake with {}: {}", + address, err + ); + continue; + } } } - } - }); + }) + }; // Start Sandhole on SSH port let ssh_listener = TcpListener::bind((listen_address, config.ssh_port)) diff --git a/src/ssh.rs b/src/ssh.rs index 6beebb1..4857356 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -879,12 +879,8 @@ impl Handler for ServerHandler { } // Assign HTTP host through config (specified by the usual HTTP/HTTPS ports) 80 | 443 => { - // Handle TCP alias-only mode + // Handle alias-only mode if user_data.tcp_alias_only { - if self.server.disable_aliasing { - self.tx.send(b"Error: Aliasing is disabled\r\n".to_vec()); - return Ok(false); - } // HTTP host must be alias (to be accessed via local forwarding) if !self.server.is_alias(address) { self.tx.send( @@ -939,6 +935,20 @@ impl Handler for ServerHandler { .insert(TcpAlias(address.into(), 80), TcpAlias(address.into(), 80)); Ok(true) } + // Reject when HTTP is disabled + } else if self.server.disable_http { + info!( + "Failed to bind HTTP host {} ({}): HTTP is disabled", + address, self.peer + ); + self.tx.send( + format!( + "Cannot listen to HTTP host {} (HTTP is disabled)\r\n", + address, + ) + .into_bytes(), + ); + Ok(false) // Handle regular tunneling for HTTP services } else { // Assign an HTTP address according to server policies @@ -1013,8 +1023,22 @@ impl Handler for ServerHandler { } // Assign TCP port through config (specified by non-trivial ports) _ if !self.server.is_alias(address) => { + // Forbid binding TCP if disabled + if self.server.disable_tcp { + info!( + "Failed to bind TCP port {} ({}): TCP is disabled", + port, self.peer + ); + self.tx.send( + format!( + "Cannot listen to TCP on port {}:{} (TCP is disabled)\r\n", + &self.server.domain, port, + ) + .into_bytes(), + ); + Ok(false) // Forbid binding TCP on alias-only mode - if user_data.tcp_alias_only { + } else if user_data.tcp_alias_only { info!( "Failed to bind TCP port {} ({}): session is in alias-only mode", port, self.peer diff --git a/tests/alias_disable_aliasing.rs b/tests/config_disable_aliasing.rs similarity index 98% rename from tests/alias_disable_aliasing.rs rename to tests/config_disable_aliasing.rs index 5fe301d..53a6d52 100644 --- a/tests/alias_disable_aliasing.rs +++ b/tests/config_disable_aliasing.rs @@ -23,7 +23,7 @@ use tokio::{ use tower::Service; #[tokio::test(flavor = "multi_thread")] -async fn alias_disable_aliasing() { +async fn config_disable_aliasing() { // 1. Initialize Sandhole let config = ApplicationConfig::parse_from([ "sandhole", @@ -112,7 +112,7 @@ async fn alias_disable_aliasing() { .tcpip_forward("test.foobar.tld", 80) .await .is_ok(), - "shouldn't have failed to bind HTTP" + "shouldn't have failed to bind regular HTTP" ); // 3. Start SSH proxy that will fail to local forward diff --git a/tests/config_disable_http.rs b/tests/config_disable_http.rs new file mode 100644 index 0000000..1994ff8 --- /dev/null +++ b/tests/config_disable_http.rs @@ -0,0 +1,143 @@ +use std::{sync::Arc, time::Duration}; + +use async_trait::async_trait; +use axum::{routing::get, Router}; +use clap::Parser; +use http::{Request, StatusCode}; +use hyper::{body::Incoming, service::service_fn}; +use hyper_util::{ + rt::{TokioExecutor, TokioIo}, + server::conn::auto::Builder, +}; +use russh::{ + client::{Msg, Session}, + Channel, +}; +use russh_keys::{key::PrivateKeyWithHashAlg, load_secret_key}; +use sandhole::{entrypoint, ApplicationConfig}; +use tokio::{ + net::TcpStream, + time::{sleep, timeout}, +}; +use tower::Service; + +#[tokio::test(flavor = "multi_thread")] +async fn config_disable_http() { + // 1. Initialize Sandhole + let config = ApplicationConfig::parse_from([ + "sandhole", + "--domain=foobar.tld", + "--user-keys-directory", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/user_keys"), + "--admin-keys-directory", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/admin_keys"), + "--certificates-directory", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/certificates"), + "--private-key-file", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/server_keys/ssh"), + "--acme-cache-directory", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/acme_cache"), + "--disable-directory-creation", + "--listen-address=127.0.0.1", + "--ssh-port=18022", + "--http-port=18080", + "--https-port=18443", + "--acme-use-staging", + "--bind-hostnames=none", + "--disable-http", + "--idle-connection-timeout=1s", + "--authentication-request-timeout=5s", + "--http-request-timeout=5s", + ]); + tokio::spawn(async move { entrypoint(config).await }); + if timeout(Duration::from_secs(5), async { + while let Err(_) = TcpStream::connect("127.0.0.1:18022").await { + sleep(Duration::from_millis(100)).await; + } + }) + .await + .is_err() + { + panic!("Timeout waiting for Sandhole to start.") + }; + assert!( + TcpStream::connect("127.0.0.1:18080").await.is_err(), + "shouldn't listen on HTTP port" + ); + assert!( + TcpStream::connect("127.0.0.1:18443").await.is_err(), + "shouldn't listen on HTTPS port" + ); + + // 2. Start SSH client that will fail to bind HTTP + 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_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( + "user1", + PrivateKeyWithHashAlg::new(Arc::new(key), None).unwrap() + ) + .await + .expect("SSH authentication failed"), + "authentication didn't succeed" + ); + assert!( + session_one.tcpip_forward("some.address", 80).await.is_err(), + "should've failed to bind HTTP" + ); + assert!(!session_one.is_closed(), "shouldn't have closed connection"); + let channel = session_one + .channel_open_session() + .await + .expect("channel_open_session_failed"); + channel + .exec(true, "tcp-alias") + .await + .expect("shouldn't error synchronously for exec"); + assert!(!session_one.is_closed(), "shouldn't have closed connection"); + assert!( + session_one.tcpip_forward("some.address", 80).await.is_ok(), + "shouldn't have failed to create HTTP alias" + ); +} + +struct SshClient; + +#[async_trait] +impl russh::client::Handler for SshClient { + type Error = anyhow::Error; + + async fn check_server_key(&mut self, _key: &ssh_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> { + let router = Router::new() + .route("/", get(|| async move { StatusCode::NO_CONTENT })) + .into_service(); + let service = service_fn(move |req: Request| router.clone().call(req)); + tokio::spawn(async move { + Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(TokioIo::new(channel.into_stream()), service) + .await + .expect("Invalid request"); + }); + Ok(()) + } +} diff --git a/tests/config_disable_tcp.rs b/tests/config_disable_tcp.rs new file mode 100644 index 0000000..6544249 --- /dev/null +++ b/tests/config_disable_tcp.rs @@ -0,0 +1,126 @@ +use std::{sync::Arc, time::Duration}; + +use async_trait::async_trait; +use axum::{routing::get, Router}; +use clap::Parser; +use http::{Request, StatusCode}; +use hyper::{body::Incoming, service::service_fn}; +use hyper_util::{ + rt::{TokioExecutor, TokioIo}, + server::conn::auto::Builder, +}; +use russh::{ + client::{Msg, Session}, + Channel, +}; +use russh_keys::{key::PrivateKeyWithHashAlg, load_secret_key}; +use sandhole::{entrypoint, ApplicationConfig}; +use tokio::{ + net::TcpStream, + time::{sleep, timeout}, +}; +use tower::Service; + +#[tokio::test(flavor = "multi_thread")] +async fn config_disable_tcp() { + // 1. Initialize Sandhole + let config = ApplicationConfig::parse_from([ + "sandhole", + "--domain=foobar.tld", + "--user-keys-directory", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/user_keys"), + "--admin-keys-directory", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/admin_keys"), + "--certificates-directory", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/certificates"), + "--private-key-file", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/server_keys/ssh"), + "--acme-cache-directory", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/acme_cache"), + "--disable-directory-creation", + "--listen-address=127.0.0.1", + "--ssh-port=18022", + "--http-port=18080", + "--https-port=18443", + "--acme-use-staging", + "--bind-hostnames=all", + "--disable-tcp", + "--idle-connection-timeout=1s", + "--authentication-request-timeout=5s", + "--http-request-timeout=5s", + ]); + tokio::spawn(async move { entrypoint(config).await }); + if timeout(Duration::from_secs(5), async { + while let Err(_) = TcpStream::connect("127.0.0.1:18022").await { + 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 TCP + 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_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( + "user1", + PrivateKeyWithHashAlg::new(Arc::new(key), None).unwrap() + ) + .await + .expect("SSH authentication failed"), + "authentication didn't succeed" + ); + assert!( + session_one.tcpip_forward("localhost", 12345).await.is_err(), + "should've failed to bind TCP" + ); + assert!(!session_one.is_closed(), "shouldn't have closed connection"); + assert!( + TcpStream::connect("127.0.0.1:12345").await.is_err(), + "shouldn't listen on TCP port" + ); +} + +struct SshClient; + +#[async_trait] +impl russh::client::Handler for SshClient { + type Error = anyhow::Error; + + async fn check_server_key(&mut self, _key: &ssh_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> { + let router = Router::new() + .route("/", get(|| async move { StatusCode::NO_CONTENT })) + .into_service(); + let service = service_fn(move |req: Request| router.clone().call(req)); + tokio::spawn(async move { + Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(TokioIo::new(channel.into_stream()), service) + .await + .expect("Invalid request"); + }); + Ok(()) + } +}