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(())
+ }
+}