diff options
| author | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-01-25 10:50:26 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-01-25 10:50:26 +0100 |
| commit | 768227f7aa31e2930e1ff2641c24fbf06fc339fe (patch) | |
| tree | ae4bf7bf696e67fedad8c8412fdd4172a51120a6 /src | |
| parent | dc3d68214e2cd4b5f86279a7fa7b7d64c341b446 (diff) | |
Add 1.0.0
Diffstat (limited to 'src')
| -rw-r--r-- | src/configuration.rs | 77 | ||||
| -rw-r--r-- | src/main.rs | 104 | ||||
| -rw-r--r-- | src/proxy.rs | 331 |
3 files changed, 428 insertions, 84 deletions
diff --git a/src/configuration.rs b/src/configuration.rs index 1437594..af5c46d 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -1,45 +1,78 @@ +//! # Configuration +//! +//! This module defines the configuration used to initialize both proxies. + +use log::error; use std::env; +use std::fmt::Debug; use std::sync::Arc; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ConfigurationError { + #[error("Environment variable {0} not set.")] + MissingEnvVar(String), + #[error("Failed to parse {0}.")] + ParseError(String), +} +/// Configuration for any proxy +#[derive(Debug)] pub struct ProxyConfiguration { pub local_port: u16, - pub remote_domain: String, + pub bind_address: String, + pub remote_host: String, pub remote_port: u16, pub protocol: &'static str, } +/// Aggregated configuration for both proxies, already in a reference counter. pub struct Configuration { pub imap_configuration: Arc<ProxyConfiguration>, pub smtp_configuration: Arc<ProxyConfiguration>, } impl Configuration { - pub fn new() -> Self { - Configuration { + /// Creates a new configuration object by reading the environment + /// variables. Exits if the right ones aren't found. + pub fn new() -> Result<Self, ConfigurationError> { + Ok(Configuration { imap_configuration: Arc::new(ProxyConfiguration { - local_port: env::var("LOCAL_IMAP_PORT") - .expect("LOCAL_IMAP_PORT not set") - .parse() - .expect("Invalid LOCAL_IMAP_PORT"), - remote_domain: env::var("REMOTE_IMAP_DOMAIN").expect("REMOTE_IMAP_DOMAIN not set"), - remote_port: env::var("REMOTE_IMAP_PORT") - .expect("REMOTE_IMAP_PORT not set") - .parse() - .expect("Invalid REMOTE_IMAP_PORT"), + local_port: get_env_number("LOCAL_IMAP_PORT", 143)?, + bind_address: get_env_var("LOCAL_IMAP_BIND_ADDRESS", Some("0.0.0.0".to_string()))?, + remote_host: get_env_var("REMOTE_IMAP_DOMAIN", None)?, + remote_port: get_env_number("REMOTE_IMAP_PORT", 993)?, protocol: "IMAP", }), smtp_configuration: Arc::new(ProxyConfiguration { - local_port: env::var("LOCAL_SMTP_PORT") - .expect("LOCAL_SMTP_PORT not set") - .parse() - .expect("Invalid LOCAL_SMTP_PORT"), - remote_domain: env::var("REMOTE_SMTP_DOMAIN").expect("REMOTE_SMTP_DOMAIN not set"), - remote_port: env::var("REMOTE_SMTP_PORT") - .expect("REMOTE_SMTP_PORT not set") - .parse() - .expect("Invalid REMOTE_SMTP_PORT"), + local_port: get_env_number("LOCAL_SMTP_PORT", 25)?, + bind_address: get_env_var("LOCAL_SMTP_BIND_ADDRESS", Some("0.0.0.0".to_string()))?, + remote_host: get_env_var("REMOTE_SMTP_DOMAIN", None)?, + remote_port: get_env_number("REMOTE_SMTP_PORT", 993)?, protocol: "SMTP", }), - } + }) + } +} + +/// Get an environment variable or return an error. +fn get_env_var(name: &str, default: Option<String>) -> Result<String, ConfigurationError> { + match env::var(name) { + Ok(value) => Ok(value), + Err(_) => match default { + Some(default_value) => Ok(default_value), + None => Err(ConfigurationError::MissingEnvVar(name.to_string())), + }, + } +} + +/// Get an environment variable and parse it as a number. Return a default +/// if not set. +fn get_env_number(name: &str, default: u16) -> Result<u16, ConfigurationError> { + match env::var(name) { + Ok(value) => value + .parse() + .map_err(|_| ConfigurationError::ParseError(name.to_string())), + Err(_) => Ok(default), } } diff --git a/src/main.rs b/src/main.rs index 858e460..863b2ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,107 @@ +//! # olden-mail +//! +//! `olden-mail` is an IMAP & SMTP proxy that allows you to connect to SSL +//! enabled mail servers using insecure plaintext connections. +//! +//! This is of course very insecure, but it's intended to allow vintage +//! computers that don't have SSL capability or ther right ciphers to still +//! be used for e-mail. +//! +//! ## Installation +//! +//! ### Pre-built binaries and packages +//! +//! Binaries are available for macos and linux, for both aarch64 and x86_64 +//! from https://build.r.bdr.sh/olden-mail, including `.deb` and `.rpm` +//! packages. +//! +//! ### Homebrew +//! +//! You can also install this on macos via homebrew. +//! +//! ``` +//! % brew tap rbdr/apps git@git.sr.ht:~rbdr/homebrew-apps +//! % brew install rbdr/apps/olden-mail +//! ``` +//! +//! ### From source +//! +//! You can run `make` for a debug build, or `make -e profile=release` for a +//! release build. +//! +//! ## Usage +//! +//! The proxy requires you to set environment variables, but otherwise takes +//! no options. +//! +//! * `LOCAL_IMAP_PORT` u16, the port in which the server will listen for +//! IMAP clients. Defaults to 143. +//! * `LOCAL_IMAP_BIND_ADDRESS` String, the address on which to listen for +//! IMAP clients. Defaults to 0.0.0.0. +//! * `REMOTE_IMAP_PORT` u16, the port to which the server will forward the +//! IMAP messages. Defaults to 993. +//! * `REMOTE_IMAP_HOST` String, the host to which the server will forward the +//! IMAP messages. Required. +//! +//! * `LOCAL_SMTP_PORT` u16, the port in which the server will listen for +//! SMTP clients. Defaults to 25. +//! * `LOCAL_SMTP_BIND_ADDRESS` String, the address on which to listen for +//! SMTP clients. Defaults to 0.0.0.0. +//! * `REMOTE_SMTP_PORT` u16, the port to which the server will forward the +//! SMTP messages. Defaults to 465. +//! * `REMOTE_SMTP_HOST` String, the host to which the server will forward the +//! SMTP messages. Required. +//! +//! This means the minimum invocation is this (Shown here with inline +//! environment variables) +//! +//! ``` +//! % REMOTE_IMAP_DOMAIN=imap.coolmailsite.example REMOTE_SMTP_DOMAIN=smtp.coolmailsite.example olden-mail +//! ``` +//! +//! ## Debugging +//! +//! You can control how much it prints by setting `RUST_LOG`. Setting it to +//! `debug` will output the whole protocol stream. The default level is +//! `info`. + +use env_logger::{Builder, Env}; +use log::{error, info}; +use std::process::exit; +use std::sync::mpsc; + mod configuration; mod proxy; use configuration::Configuration; -use proxy::create_proxy; +use proxy::ProxyServer; fn main() { - let configuration = Configuration::new(); + Builder::from_env(Env::default().default_filter_or("info")) + .format_timestamp_millis() + .init(); + + let configuration = Configuration::new().unwrap_or_else(|error| { + error!("{error}"); + exit(1); + }); + + let (tx, rx) = mpsc::channel(); + + let mut imap_proxy = ProxyServer::new(configuration.imap_configuration); + let mut smtp_proxy = ProxyServer::new(configuration.smtp_configuration); - let imap_proxy = create_proxy(configuration.imap_configuration); - let smtp_proxy = create_proxy(configuration.smtp_configuration); + ctrlc::set_handler(move || { + info!("Shutting down..."); + imap_proxy.shutdown(); + smtp_proxy.shutdown(); + let _ = tx.send(()); + }) + .unwrap_or_else(|e| { + error!("Error setting Ctrl-C handler: {e}"); + std::process::exit(1); + }); - imap_proxy.join().unwrap(); - smtp_proxy.join().unwrap(); + // Block until we get a shutdown + rx.recv().unwrap_or(()); } diff --git a/src/proxy.rs b/src/proxy.rs index ce40e6e..c6954a0 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,86 +1,305 @@ +//! # Proxy Module +//! +//! This module has the actual proxy functionality, exposed through +//! `ProxyServer`. The proxy consists of a local unencrypted TCP stream +//! and a remote TLS stream. Messages are passed between them via two +//! threads. +//! +//! Each new client connection spawns two threads: +//! - **Client to Server Thread**: Forwards data from client -> TLS server +//! - **Serveer to Client Thread**: Forwards data from TLS server -> client +//! +//! Finally, the ProxyServer may be shutdown by calling `.shutdown()`, +//! this will stop new connections and wait for it to finish. +//! +//! # Example +//! +//! ``` +//! use std::sync::Arc; +//! use crate::configuration::ProxyConfiguration; +//! use crate::proxy::ProxyServer; +//! +//! let config = Arc::new(ProxyConfiguration { +//! protocol: "IMAP".to_string(), +//! local_port: 143, +//! remote_domain: "imap.example.com".to_string(), +//! remote_port: 993, +//! }); +//! +//! let mut server = ProxyServer::new(config); +//! // The server runs in a background thread. To shut down gracefully: +//! server.shutdown(); +//! ``` +use log::{debug, error, info}; use native_tls::TlsConnector; -use std::io::{Read, Write}; +use std::io::{ErrorKind, Read, Write}; use std::net::{TcpListener, TcpStream}; -use std::sync::Arc; -use std::thread::{spawn, JoinHandle}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; +use std::thread::{sleep, spawn, JoinHandle}; +use std::time::Duration; use crate::configuration::ProxyConfiguration; -pub fn create_proxy(configuration: Arc<ProxyConfiguration>) -> JoinHandle<()> { - let cloned_configuration = Arc::clone(&configuration); - spawn(move || { - run_proxy(cloned_configuration); - }) +/// A proxy server that listens for plaintext connections and forwards them +/// via TLS. +/// +/// Creating a new `ProxyServer` spawns a dedicated thread that: +/// - Binds to a local port (non-blocking mode). +/// - Spawns additional threads for each incoming client connection. +/// - Manages connection-lifetime until it receives a shutdown signal. +pub struct ProxyServer { + running: Arc<AtomicBool>, + thread_handle: Option<JoinHandle<()>>, } -fn run_proxy(configuration: Arc<ProxyConfiguration>) { - let listener = TcpListener::bind(format!("0.0.0.0:{}", configuration.local_port)).unwrap(); +impl ProxyServer { + /// Creates a new `ProxyServer` for the given `ProxyConfiguration` and + /// immediately starts it. + /// + /// # Arguments + /// + /// * `configuration` - Shared (Arc) `ProxyConfiguration` + /// + /// # Returns + /// + /// A `ProxyServer` instance that will keep running until its `.shutdown()` + /// method is called, or an error occurs. + pub fn new(configuration: Arc<ProxyConfiguration>) -> Self { + let running = Arc::new(AtomicBool::new(true)); + let running_clone = Arc::clone(&running); - println!("Proxy listening on port {}", configuration.local_port); + let thread_handle = spawn(move || { + run_proxy(configuration, running_clone); + }); - for stream in listener.incoming() { - match stream { - Ok(stream) => { - let cloned_configuration = Arc::clone(&configuration); - spawn(move || { - handle_client(stream, cloned_configuration); + ProxyServer { + running, + thread_handle: Some(thread_handle), + } + } + + /// Signals this proxy to stop accepting new connections and waits + /// for all active connection threads to complete. + pub fn shutdown(&mut self) { + self.running.store(false, Ordering::SeqCst); + if let Some(handle) = self.thread_handle.take() { + let _ = handle.join(); + } + } +} + +/// The main loop that listens for incoming (plaintext) connections on +/// `configuration.bind_address:configuration.local_port`. +fn run_proxy(configuration: Arc<ProxyConfiguration>, running: Arc<AtomicBool>) { + let listener = match TcpListener::bind(format!( + "{}:{}", + configuration.bind_address, configuration.local_port + )) { + Ok(l) => l, + Err(e) => { + error!("Failed to bind to port {}: {}", configuration.local_port, e); + return; + } + }; + listener.set_nonblocking(true).unwrap(); + + info!( + "{} proxy listening on port {}:{}", + configuration.protocol, configuration.bind_address, configuration.local_port + ); + + // Keep track of active connections so we can join them on shutdown + let mut active_threads = vec![]; + + while running.load(Ordering::SeqCst) { + match listener.accept() { + Ok((stream, addr)) => { + info!("New {} connection from {}", configuration.protocol, addr); + + let configuration_clone = Arc::clone(&configuration); + let handle = spawn(move || { + handle_client(stream, configuration_clone); }); + active_threads.push(handle); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // No pending connection; sleep briefly then loop again + sleep(Duration::from_millis(100)); + continue; } Err(e) => { - eprintln!("Failed to accept connection: {}", e); + error!("Error accepting connection: {}", e); + break; } } + + // Clean up any finished threads + active_threads.retain(|thread| !thread.is_finished()); + + // Potential Improvement: Configure thread limit. + if active_threads.len() >= 50 { + sleep(Duration::from_millis(100)); + } + } + + // On shutdown, wait for all threads to finish + for thread in active_threads { + let _ = thread.join(); } } -fn handle_client(mut client_stream: TcpStream, configuration: Arc<ProxyConfiguration>) { - let connector = TlsConnector::new().unwrap(); - let remote_stream = TcpStream::connect(format!( - "{}:{}", - configuration.remote_domain, configuration.remote_port - )) - .unwrap(); - let mut remote_stream = connector - .connect(&configuration.remote_domain, remote_stream) - .unwrap(); +/// Handles a single client connection by bridging it (plaintext) to a TLS connection. +fn handle_client(client_stream: TcpStream, configuration: Arc<ProxyConfiguration>) { + if let Err(e) = client_stream.set_nonblocking(true) { + error!("Failed to set client stream to nonblocking: {}", e); + return; + } - let mut client_stream_clone = client_stream.try_clone().unwrap(); - let mut remote_stream_clone = remote_stream.get_ref().try_clone().unwrap(); + let connector = match TlsConnector::new() { + Ok(c) => c, + Err(e) => { + error!("Failed to create TLS connector: {}", e); + return; + } + }; - let cloned_configuration = Arc::clone(&configuration); - spawn(move || { - forward_stream(&mut client_stream, &mut remote_stream, cloned_configuration); - }); - forward_stream( - &mut remote_stream_clone, - &mut client_stream_clone, - configuration, + let remote_addr = format!( + "{}:{}", + configuration.remote_host, configuration.remote_port ); -} + let tcp_stream = match TcpStream::connect(&remote_addr) { + Ok(stream) => stream, + Err(e) => { + error!("Failed to connect to {}: {}", remote_addr, e); + return; + } + }; + + let tls_stream = match connector.connect(&configuration.remote_host, tcp_stream) { + Ok(tls_stream) => tls_stream, + Err(e) => { + error!( + "TLS handshake to {} failed: {}", + configuration.remote_host, e + ); + return; + } + }; + + // The nonblocking needs to be set AFTER the TLS handshake is completed. + // Otherwise the TLS handshake is interrupted. + if let Err(e) = tls_stream.get_ref().set_nonblocking(true) { + error!("Failed to set remote stream to nonblocking: {}", e); + return; + } + + let tls_stream = Arc::new(Mutex::new(tls_stream)); + + let client_stream_clone = match client_stream.try_clone() { + Ok(s) => s, + Err(e) => { + error!("Failed to clone client stream: {}", e); + return; + } + }; + + // Client to Server Thread + let tls_stream_clone = Arc::clone(&tls_stream); + let client_to_server = spawn(move || { + debug!(">>> BEGIN"); + let mut buffer = [0u8; 8192]; + let mut client_reader = client_stream; + loop { + debug!(">"); + let bytes_read = match client_reader.read(&mut buffer) { + Ok(0) => break, + Ok(n) => n, + Err(ref e) if e.kind() == ErrorKind::WouldBlock => { + sleep(Duration::from_millis(10)); + continue; + } + Err(error) => { + debug!(">>> Error reading buffer {error}"); + break; + } + }; -fn forward_stream<R: Read, W: Write>( - from: &mut R, - to: &mut W, - configuration: Arc<ProxyConfiguration>, -) { - let mut buffer = [0; 4096]; - loop { - match from.read(&mut buffer) { - Ok(0) => break, // EOF - Ok(n) => { - if let Err(e) = to.write_all(&buffer[..n]) { - eprintln!("{} proxy write error: {}", configuration.protocol, e); + let debug_str = String::from_utf8_lossy(&buffer[..bytes_read]) + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t"); + debug!(">>> {}", debug_str); + + // Lock the TLS stream and write the data to server + match tls_stream_clone.lock() { + Ok(mut tls_guard) => { + if let Err(error) = tls_guard.write_all(&buffer[..bytes_read]) { + debug!(">>> Error writing to server: {error}"); + break; + } + if let Err(error) = tls_guard.flush() { + debug!(">>> Error flushing server connection: {error}"); + break; + } + } + Err(error) => { + debug!(">>> Error acquiring TLS stream lock: {error}"); break; } - if let Err(e) = to.flush() { - eprintln!("{} proxy flush error: {}", configuration.protocol, e); + } + } + }); + + // Server to Client Thread + let tls_stream_clone = Arc::clone(&tls_stream); + let server_to_client = spawn(move || { + debug!("<<< BEGIN"); + let mut buffer = [0u8; 8192]; + let mut client_writer = client_stream_clone; + loop { + debug!("<"); + // Lock the TLS stream and read from the server + let bytes_read = match tls_stream_clone.lock() { + Ok(mut tls_guard) => match tls_guard.read(&mut buffer) { + Ok(0) => break, // TLS server closed + Ok(n) => n, + Err(ref e) if e.kind() == ErrorKind::WouldBlock => { + sleep(Duration::from_millis(10)); + continue; + } + Err(error) => { + debug!("<<< Error reading buffer {error}"); + break; + } + }, + Err(error) => { + debug!("<<< Error Cloning TLS {error}"); break; } + }; + + let debug_str = String::from_utf8_lossy(&buffer[..bytes_read]) + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t"); + debug!("<<< {}", debug_str); + + // Write decrypted data to client + if client_writer.write_all(&buffer[..bytes_read]).is_err() { + debug!("<<< ERR"); + break; } - Err(e) => { - eprintln!("{} proxy read error: {}", configuration.protocol, e); + if client_writer.flush().is_err() { + debug!("<<< ERR"); break; } } - } + }); + + // Wait for both directions to finish + let _ = client_to_server.join(); + let _ = server_to_client.join(); } |