diff --git a/CHANGELOG.md b/CHANGELOG.md index c7dc725..33fdd37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Add key algorithm to user details in the admin interface. - Add `--disable-http` CLI flag. - Add `--disable-tcp` CLI flag. - Add `--random-subdomain-filter-profanities` CLI flag. @@ -15,6 +16,8 @@ - Separate TCP and alias logic. - Remove inaccesible tabs from admin interface when using one or more of the `--disable-*` flags. +- Set nodelay for TCP streams. +- Don't duplicate fingerprints logic. ## 0.3.0 (2024-12-28) diff --git a/book/src/faq.md b/book/src/faq.md index f06c24c..78c9682 100644 --- a/book/src/faq.md +++ b/book/src/faq.md @@ -20,6 +20,14 @@ Host mysshserver.com Port 2222 ``` +## 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. + ## How do I prevent multiple services from load-balancing? With the `--load-balancing=deny` or `--load-balancing=replace` [CLI flag](./cli.md). This is currently a global setting. @@ -28,10 +36,6 @@ With the `--load-balancing=deny` or `--load-balancing=replace` [CLI flag](./cli. With the `--force-https` [CLI flag](./cli.md). This is currently a global setting. -## How do I enable Websockets? - -Websockets are always enabled for HTTP services. +## How do I allow/block certain IP ranges? -## 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. +With the `--ip-allowlist` and `--ip-blocklist` [CLI flags](./cli.md) respectively. These are currently a global setting. diff --git a/src/admin.rs b/src/admin.rs index ce0d416..7dfe414 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -267,23 +267,27 @@ impl AdminState { Line::from(vec![" ".bold(), "Close ".into()]).centered() }, ); - let (user_type, comment) = data + let user_data = data .as_ref() // If fingerprint, get key data .map(|(_, data)| { - ( - format!("Type: {}", data.auth), - format!("Key comment: {}", data.comment), - ) + vec![ + Line::from(user.as_str()).centered(), + Line::from(format!("Type: {}", data.auth)).centered(), + Line::from(format!("Key comment: {}", data.comment)).centered(), + Line::from(format!("Algorithm: {}", data.algorithm.as_str())) + .centered(), + ] }) // If not, get generic user data - .unwrap_or(("Type: User".into(), "(authenticated with password)".into())); - let text = Paragraph::new(vec![ - Line::from(user.as_str()).centered(), - Line::from(user_type).centered(), - Line::from(comment).centered(), - ]) - .wrap(Wrap { trim: true }); + .unwrap_or_else(|| { + vec![ + Line::from(user.as_str()).centered(), + Line::from("Type: User").centered(), + Line::from("(authenticated with password)").centered(), + ] + }); + let text = Paragraph::new(user_data).wrap(Wrap { trim: true }); Widget::render(block, area, buf); Widget::render(text, inner, buf); } diff --git a/src/fingerprints.rs b/src/fingerprints.rs index 79ecccc..4905134 100644 --- a/src/fingerprints.rs +++ b/src/fingerprints.rs @@ -1,7 +1,7 @@ use std::{ collections::BTreeMap, fs::remove_file, - path::PathBuf, + path::{Path, PathBuf}, sync::{Arc, RwLock}, time::Duration, }; @@ -9,7 +9,7 @@ use std::{ use crate::{directory::watch_directory, droppable_handle::DroppableHandle, error::ServerError}; use log::{error, warn}; use notify::RecommendedWatcher; -use ssh_key::{Fingerprint, HashAlg, PublicKey}; +use ssh_key::{Algorithm, Fingerprint, HashAlg, PublicKey}; use tokio::{ fs::{read_dir, read_to_string}, sync::oneshot, @@ -43,6 +43,8 @@ pub(crate) struct KeyData { pub(crate) file: PathBuf, // The key's comment. pub(crate) comment: String, + // The key's algorithm. + pub(crate) algorithm: Algorithm, // Authentication type associated with the key. pub(crate) auth: AuthenticationType, } @@ -62,6 +64,57 @@ pub(crate) struct FingerprintsValidator { _watchers: [RecommendedWatcher; 2], } +// Helper function with duplicated functionality for user and admin keys +async fn load_fingerprints_data( + directory: &Path, + fingerprints_data: &RwLock>, + auth: AuthenticationType, +) { + let mut fingerprints_map = BTreeMap::new(); + match read_dir(directory).await { + Ok(mut read_dir) => { + // For each file in the admin keys directory + while let Ok(Some(entry)) = read_dir.next_entry().await { + match read_to_string(entry.path()).await { + Ok(data) => { + fingerprints_map.extend( + // Try to find a key for each line + data.lines() + .filter(|line| !line.trim().is_empty()) + .flat_map(|line| match PublicKey::from_openssh(line) { + Ok(key) => Some(key), + Err(err) => { + warn!("Unable to parse key in {:?}: {}", entry, err); + None + } + }) + // Generate the SHA256 fingerprint and metadata for the given key + .map(|key| { + ( + key.fingerprint(HashAlg::Sha256), + KeyData { + file: entry.path(), + comment: key.comment().into(), + algorithm: key.algorithm(), + auth, + }, + ) + }), + ); + } + Err(err) => { + warn!("Unable to load key in {:?}: {}", entry.file_name(), err); + } + } + } + *fingerprints_data.write().unwrap() = fingerprints_map; + } + Err(err) => { + error!("Unable to read keys directory {:?}: {}", &directory, err); + } + } +} + impl FingerprintsValidator { // Start watching on the directories, waiting for user/admin keys that get added or removed. pub(crate) async fn watch( @@ -91,57 +144,12 @@ impl FingerprintsValidator { if user_rx.changed().await.is_err() { break; } - let mut user_set = BTreeMap::new(); - match read_dir(user_keys_directory.as_path()).await { - Ok(mut read_dir) => { - // For each file in the user keys directory - while let Ok(Some(entry)) = read_dir.next_entry().await { - match read_to_string(entry.path()).await { - Ok(data) => { - user_set.extend( - // Try to find a key for each line - data.lines() - .flat_map(|line| match PublicKey::from_openssh(line) { - Ok(key) => Some(key), - Err(err) => { - warn!( - "Unable to parse key in {:?} - {}", - entry, err - ); - None - } - }) - // Generate the SHA256 fingerprint and metadata for the given key - .map(|key| { - ( - key.fingerprint(HashAlg::Sha256), - KeyData { - file: entry.path(), - comment: key.comment().into(), - auth: AuthenticationType::User, - }, - ) - }), - ); - } - Err(err) => { - warn!( - "Unable to load user key in {:?}: {}", - entry.file_name(), - err - ); - } - } - } - *user_fingerprints_clone.write().unwrap() = user_set; - } - Err(err) => { - error!( - "Unable to read user keys directory {:?}: {}", - &user_keys_directory, err - ); - } - } + load_fingerprints_data( + user_keys_directory.as_path(), + &user_fingerprints_clone, + AuthenticationType::User, + ) + .await; // Notify about initial keys population if let Some(tx) = user_init_tx.take() { let _ = tx.send(()); @@ -158,57 +166,12 @@ impl FingerprintsValidator { if admin_rx.changed().await.is_err() { break; } - let mut admin_set = BTreeMap::new(); - match read_dir(admin_keys_directory.as_path()).await { - Ok(mut read_dir) => { - // For each file in the admin keys directory - while let Ok(Some(entry)) = read_dir.next_entry().await { - match read_to_string(entry.path()).await { - Ok(data) => { - admin_set.extend( - // Try to find a key for each line - data.lines() - .flat_map(|line| match PublicKey::from_openssh(line) { - Ok(key) => Some(key), - Err(err) => { - warn!( - "Unable to parse key in {:?} - {}", - entry, err - ); - None - } - }) - // Generate the SHA256 fingerprint and metadata for the given key - .map(|key| { - ( - key.fingerprint(HashAlg::Sha256), - KeyData { - file: entry.path(), - comment: key.comment().into(), - auth: AuthenticationType::Admin, - }, - ) - }), - ); - } - Err(err) => { - warn!( - "Unable to load admin key in {:?}: {}", - entry.file_name(), - err - ); - } - } - } - *admin_fingerprints_clone.write().unwrap() = admin_set; - } - Err(err) => { - error!( - "Unable to read admin keys directory {:?}: {}", - &admin_keys_directory, err - ); - } - } + load_fingerprints_data( + admin_keys_directory.as_path(), + &admin_fingerprints_clone, + AuthenticationType::Admin, + ) + .await; // Notify about initial keys population if let Some(tx) = admin_init_tx.take() { let _ = tx.send(()); @@ -332,4 +295,80 @@ mod fingerprints_validator_tests { AuthenticationType::None ); } + + #[tokio::test] + async fn gets_data_for_fingerprints() { + let validator = FingerprintsValidator::watch( + USER_KEYS_DIRECTORY.parse().unwrap(), + ADMIN_KEYS_DIRECTORY.parse().unwrap(), + ) + .await + .unwrap(); + + let admin_key = parse_public_key_base64( + "AAAAC3NzaC1lZDI1NTE5AAAAIDpmDGLbC68yM87r+fD/aoEimDdnzZtmnZXCnxkIGHMq", + ) + .unwrap(); + let key_one = parse_public_key_base64( + "AAAAC3NzaC1lZDI1NTE5AAAAIMYVfXHTqf3/0W8ZQ/I8zmMirvmosV78n1qtYgVQX58W", + ) + .unwrap(); + let key_two = + parse_public_key_base64( + "AAAAB3NzaC1yc2EAAAADAQABAAABgQCUdw1f/va/ax8L/5qoZw37+76psjybsY7qNJMxOhwqKQ6fKiLu2xv+uFQxdEbNitXbcC8zZ2m98XzEPlNoY3DTqw5RAt2qZQMMXFLzDNHCpY6xT1DxLFTYxczXj9Xk4Ms7/RQP6pxLV5PIVc06HXBThCzcLMDdnl9n0jEWu1CwSGtsc87/Gvbnr3QrfrnK40IS7c5SIfbI5yN7pfnCEkRf637EGzc11Tq4e2/ujweETZ1C+KcJZapVVHTvFfITyOqLeqrgXgsMQUML48SfDUl/RsY4nk6aFKwK7f0oGzykqLTX0YHS1wxLOnPSkK33ohvtjvcUzA/eAmjUiQquJQ7DW6RPvW57lozzIxwFvO4O/j398r3W1de3R7Q3rmAwKbujFSJlZb4OvS1ZLS8md8TwCO1xwE+4aY3xvsmeHpfBcEjhTmEYEEY630hbiMgHsbH1M7uAZkbXUgw7R6cLPCndc4GiDOLN/bkKwa55evbOS1J1cD4pi5lUSnzZzk9lYrU=" + ).unwrap(); + let unknown_key = parse_public_key_base64( + "AAAAC3NzaC1lZDI1NTE5AAAAIFlIvi8Fw1QvxpkRuAMiBKGL84r2wlgxTj7iOzXWBeU4", + ) + .unwrap(); + + let admin_key_data = validator + .get_data_for_fingerprint(&admin_key.fingerprint(HashAlg::Sha256)) + .unwrap(); + assert_eq!(admin_key_data.auth, AuthenticationType::Admin); + assert_eq!(admin_key_data.comment, "admin"); + assert_eq!(admin_key_data.algorithm.as_str(), "ssh-ed25519"); + assert_eq!( + admin_key_data.file.to_string_lossy(), + concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/data/admin_keys/admin.pub" + ) + ); + + let user_one_key_data = validator + .get_data_for_fingerprint(&key_one.fingerprint(HashAlg::Sha256)) + .unwrap(); + assert_eq!(user_one_key_data.auth, AuthenticationType::User); + assert_eq!(user_one_key_data.comment, "key1"); + assert_eq!(user_one_key_data.algorithm.as_str(), "ssh-ed25519"); + assert_eq!( + user_one_key_data.file.to_string_lossy(), + concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/data/user_keys/keys_1_2.pub" + ) + ); + + let user_two_key_data = validator + .get_data_for_fingerprint(&key_two.fingerprint(HashAlg::Sha256)) + .unwrap(); + assert_eq!(user_two_key_data.auth, AuthenticationType::User); + assert_eq!(user_two_key_data.comment, "key2"); + assert_eq!(user_two_key_data.algorithm.as_str(), "ssh-rsa"); + assert_eq!( + user_two_key_data.file.to_string_lossy(), + concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/data/user_keys/keys_1_2.pub" + ) + ); + + assert!( + validator + .get_data_for_fingerprint(&unknown_key.fingerprint(HashAlg::Sha256)) + .is_none(), + "shouldn't have data for unknown key" + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 5d41b13..daa07ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -243,12 +243,10 @@ pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { .with_context(|| "Error creating admin keys directory")?; } // Listen on the user_keys and admin_keys directories for new SSH public keys. - let fingerprints = FingerprintsValidator::watch( - config.user_keys_directory.clone(), - config.admin_keys_directory.clone(), - ) - .await - .with_context(|| "Error setting up public keys watcher")?; + let fingerprints = + FingerprintsValidator::watch(config.user_keys_directory, config.admin_keys_directory) + .await + .with_context(|| "Error setting up public keys watcher")?; // Initialize the login API service if a URL has been set. let api_login = config .password_authentication_url @@ -281,12 +279,9 @@ pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { } // Listen on the certificates sub-directories for updates to Let's Encrypt certificates. let certificates = Arc::new( - CertificateResolver::watch( - config.certificates_directory.clone(), - RwLock::new(alpn_resolver), - ) - .await - .with_context(|| "Error setting up certificates watcher")?, + CertificateResolver::watch(config.certificates_directory, RwLock::new(alpn_resolver)) + .await + .with_context(|| "Error setting up certificates watcher")?, ); // Initialize the IP address allowlist/blocklist service. let ip_filter = Arc::new(IpFilter::new(IpFilterConfig { @@ -347,8 +342,8 @@ pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { }; let addressing = Arc::new(AddressDelegator::new(AddressDelegatorData { resolver: DnsResolver::new(), - txt_record_prefix: config.txt_record_prefix.trim_matches('.').to_string(), - root_domain: config.domain.trim_matches('.').to_string(), + txt_record_prefix: config.txt_record_prefix, + root_domain: config.domain.clone(), bind_hostnames: config.bind_hostnames, force_random_subdomains: !config.allow_requested_subdomains, random_subdomain_seed: config.random_subdomain_seed, @@ -567,6 +562,9 @@ pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { info!("Rejecting HTTP connection for {}: not allowed", ip); continue; } + if let Err(err) = stream.set_nodelay(true) { + warn!("Error setting nodelay for {}: {}", address, err); + } // 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)) @@ -629,6 +627,9 @@ pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { info!("Rejecting HTTPS connection for {}: not allowed", ip); continue; } + if let Err(err) = stream.set_nodelay(true) { + warn!("Error setting nodelay for {}: {}", address, err); + } if config.connect_ssh_on_https_port { // Check if this is an SSH-2.0 handshake. let mut buf = [0u8; 8]; @@ -723,6 +724,9 @@ pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { info!("Rejecting SSH connection for {}: not allowed", ip); continue; } + if let Err(err) = stream.set_nodelay(true) { + warn!("Error setting nodelay for {}: {}", address, err); + } handle_ssh_connection(stream, address, &ssh_config, &mut sandhole).await; } _ = &mut signal_handler => { diff --git a/src/tcp.rs b/src/tcp.rs index 4500900..cdf358a 100644 --- a/src/tcp.rs +++ b/src/tcp.rs @@ -10,7 +10,7 @@ use crate::{ use anyhow::Context; use async_trait::async_trait; use dashmap::DashMap; -use log::{error, info}; +use log::{error, info, warn}; use tokio::{io::copy_bidirectional, net::TcpListener, time::timeout}; // Service that handles creating TCP sockets for reverse forwarding connections. @@ -81,6 +81,9 @@ impl PortHandler for Arc { info!("Rejecting TCP connection for {}: not allowed", ip); continue; } + if let Err(err) = stream.set_nodelay(true) { + warn!("Error setting nodelay for {}: {}", address, err); + } // Get the handler for this port if let Some(handler) = clone.conn_manager.get(&port) { if let Ok(mut channel) = handler diff --git a/tests/config_disable_http.rs b/tests/config_disable_http.rs index 3cdac4f..5b930ab 100644 --- a/tests/config_disable_http.rs +++ b/tests/config_disable_http.rs @@ -111,6 +111,31 @@ async fn config_disable_http() { session_one.tcpip_forward("some.proxy", 90).await.is_ok(), "shouldn't have failed to bind alias" ); + + // 2. Start SSH client that manages 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_two = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client) + .await + .expect("Failed to connect to SSH server"); + assert!( + session_two + .authenticate_publickey( + "user1", + PrivateKeyWithHashAlg::new(Arc::new(key), None).unwrap() + ) + .await + .expect("SSH authentication failed"), + "authentication didn't succeed" + ); + assert!( + session_two.tcpip_forward("localhost", 12345).await.is_ok(), + "shouldn't have failed to bind TCP" + ); } struct SshClient; diff --git a/tests/config_disable_tcp.rs b/tests/config_disable_tcp.rs index fc049bf..0cb592e 100644 --- a/tests/config_disable_tcp.rs +++ b/tests/config_disable_tcp.rs @@ -68,11 +68,11 @@ async fn config_disable_tcp() { ) .expect("Missing file key1"); let ssh_client = SshClient; - let mut session_one = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client) + 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_one + session .authenticate_publickey( "user1", PrivateKeyWithHashAlg::new(Arc::new(key), None).unwrap() @@ -82,23 +82,20 @@ async fn config_disable_tcp() { "authentication didn't succeed" ); assert!( - session_one.tcpip_forward("localhost", 12345).await.is_err(), + session.tcpip_forward("localhost", 12345).await.is_err(), "should've failed to bind TCP" ); - assert!(!session_one.is_closed(), "shouldn't have closed connection"); + assert!(!session.is_closed(), "shouldn't have closed connection"); assert!( TcpStream::connect("127.0.0.1:12345").await.is_err(), "shouldn't listen on TCP port" ); assert!( - session_one - .tcpip_forward("test.foobar.tld", 80) - .await - .is_ok(), + session.tcpip_forward("test.foobar.tld", 80).await.is_ok(), "shouldn't have failed to bind HTTP" ); assert!( - session_one.tcpip_forward("some.alias", 12345).await.is_ok(), + session.tcpip_forward("some.alias", 12345).await.is_ok(), "shouldn't have failed to bind alias" ); }