From: Ruben Beltran del Rio Date: Sat, 25 Jan 2025 09:50:26 +0000 (+0100) Subject: Add 1.0.0 X-Git-Tag: 1.0.0~9 X-Git-Url: https://git.r.bdr.sh/rbdr/olden-mail/commitdiff_plain/768227f7aa31e2930e1ff2641c24fbf06fc339fe?ds=sidebyside Add 1.0.0 --- diff --git a/.build.yml b/.build.yml new file mode 100644 index 0000000..3f8ae0d --- /dev/null +++ b/.build.yml @@ -0,0 +1,27 @@ +image: archlinux +packages: + - make + - rsync + - coreutils + - lld + - rustup + - aarch64-linux-gnu-gcc + - tar + - gzip +sources: + - git@git.sr.ht:~rbdr/olden-mail +secrets: + - 89d3b676-25d6-4942-8231-38b73aa62bf6 + - 0b0d3e5e-fbdc-41d0-97ed-ee654fe797ff +tasks: + - set_rust: | + cd olden-mail + make set_rust + - install_coverage_tool: | + cargo install cargo-tarpaulin + - install_builders: | + cargo install cargo-generate-rpm + cargo install cargo-deb + - package: | + cd page + make ci diff --git a/Cargo.lock b/Cargo.lock index 2a7a129..75e3b66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,65 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys", +] + [[package]] name = "bitflags" version = "2.8.0" @@ -23,6 +82,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + [[package]] name = "core-foundation" version = "0.9.4" @@ -39,6 +110,39 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "ctrlc" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90eeab0aa92f3f9b4e87f258c72b139c207d251f9cbc1080a0086b86a8870dd3" +dependencies = [ + "nix", + "windows-sys", +] + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + [[package]] name = "errno" version = "0.3.10" @@ -81,6 +185,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "libc" version = "0.2.169" @@ -99,6 +215,12 @@ version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + [[package]] name = "native-tls" version = "0.2.12" @@ -116,11 +238,27 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "olden-mail" -version = "0.1.0" +version = "1.0.0" dependencies = [ + "ctrlc", + "env_logger", + "log", "native-tls", + "thiserror", ] [[package]] @@ -197,6 +335,35 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "rustix" version = "0.38.44" @@ -273,12 +440,38 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11cd88e12b17c6494200a9c1b683a04fcac9573ed74cd1b62aeb2727c5592243" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index a7114b2..9180431 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,35 @@ [package] name = "olden-mail" -version = "0.1.0" +version = "1.0.0" edition = "2021" +license = "AGPL-3.0-or-later" +description = "IMAP & SMTP proxy to connect vintage clients to modern e-mail." +homepage = "https://r.bdr.sh/olden-mail.html" +authors = ["Rubén Beltrán del Río "] [dependencies] +ctrlc = "3.4.5" +env_logger = "0.11.6" +log = "0.4.25" native-tls = "0.2.12" +thiserror = "2.0.11" + +[profile.release] +strip = true +lto = true +panic = "abort" + +[package.metadata.generate-rpm] +assets = [ + { source = "target/release/olden-mail", dest = "/usr/bin/olden-mail", mode = "755" }, + { source = "man/olden-mail.1", dest = "/usr/share/man/man1/olden-mail.1", mode = "644" }, +] + +[package.metadata.deb] +assets = [ + ["target/release/olden-mail", "/usr/bin/olden-mail", "755" ], + ["man/olden-mail.1", "/usr/share/man/man1/olden-mail.1", "644" ], +] + +[lints.clippy] +pedantic = "warn" diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c580f7 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# 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`. diff --git a/man/olden-mail.1 b/man/olden-mail.1 new file mode 100644 index 0000000..e72a08e --- /dev/null +++ b/man/olden-mail.1 @@ -0,0 +1,44 @@ +.TH OLDEN-MAIL 1 "2025-01-25" "1.0.0" "Olden Mail Manual" +.SH NAME +olden-mail \- IMAP & SMTP proxy to connect vintage clients to modern e-mail. +.SH SYNOPSIS +.B olden-mail +.SH DESCRIPTION +.PP +`olden-mail` is an IMAP & SMTP proxy that allows you to connect to SSL +enabled mail servers using insecure plaintext connections in computers that +don't support SSL or have older ciphers. +.SH ENVIRONMENT VARIABLES +You can configure the proxy through environment variables. +.TP +.B LOCAL_IMAP_PORT +The port on which to listen for IMAP clients. Defaults to 143. +.TP +.B LOCAL_IMAP_BIND_ADDRESS +The address on which to listen for IMAP clients. Defaults to 0.0.0.0. +.TP +.B REMOTE_IMAP_PORT +The TLS enabled remote port to which we will proxy the connection. Defaults to +993. +.TP +.B REMOTE_IMAP_HOST +The remote host to which we will proxy the connection. Required. +.TP +.B LOCAL_SMTP_PORT +The port on which to listen for SMTP clients. Defaults to 25. +.TP +.B LOCAL_SMTP_BIND_ADDRESS +The address on which to listen for SMTP clients. Defaults to 0.0.0.0. +.TP +.B REMOTE_SMTP_PORT +The TLS enabled remote port to which we will proxy the connection. Defaults to +465. +.TP +.B REMOTE_SMTP_HOST +The remote host to which we will proxy the connection. Required. +.SH VERSION +.BR 1.0.0 +.SH HOMEPAGE +.I https://r.bdr.sh/olden-mail.html +.SH AUTHORS +\ Rubén Beltrán del Río <\fIolden.mail@r.bdr.sh\fP> 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, pub smtp_configuration: Arc, } 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 { + 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) -> Result { + 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 { + 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) -> 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, + thread_handle: Option>, } -fn run_proxy(configuration: Arc) { - 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) -> 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, running: Arc) { + 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) { - let connector = TlsConnector::new().unwrap(); - let remote_stream = TcpStream::connect(format!( +/// Handles a single client connection by bridging it (plaintext) to a TLS connection. +fn handle_client(client_stream: TcpStream, configuration: Arc) { + if let Err(e) = client_stream.set_nonblocking(true) { + error!("Failed to set client stream to nonblocking: {}", e); + return; + } + + let connector = match TlsConnector::new() { + Ok(c) => c, + Err(e) => { + error!("Failed to create TLS connector: {}", e); + return; + } + }; + + let remote_addr = format!( "{}:{}", - configuration.remote_domain, configuration.remote_port - )) - .unwrap(); - let mut remote_stream = connector - .connect(&configuration.remote_domain, remote_stream) - .unwrap(); - - let mut client_stream_clone = client_stream.try_clone().unwrap(); - let mut remote_stream_clone = remote_stream.get_ref().try_clone().unwrap(); - - 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, + 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( - from: &mut R, - to: &mut W, - configuration: Arc, -) { - 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(); }