diff --git a/CHANGELOG.md b/CHANGELOG.md index 17096a3..67d401d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # SpamAssassin Milter changelog +## 0.3.2 (unreleased) + +### Changed + +* The command-line user interface has been reimplemented using a lighter-weight, + no-dependencies approach. + + While there are some differences in appearance and error reporting, there are + no functional changes in using the program. Make sure that you are not relying + on undocumented (and therefore unsupported) behaviour of the old CLI. + ## 0.3.1 (2022-03-14) ### Fixed diff --git a/Cargo.lock b/Cargo.lock index d53c1ef..db5595e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,17 +13,6 @@ dependencies = [ "syn", ] -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.1.0" @@ -81,30 +70,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "clap" -version = "3.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2dbdf4bdacb33466e854ce889eee8dfd5729abf7ccd7664d0a2d60cd384440b" -dependencies = [ - "atty", - "bitflags", - "clap_lex", - "indexmap", - "strsim", - "termcolor", - "textwrap", -] - -[[package]] -name = "clap_lex" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a37c35f1112dad5e6e0b1adaff798507497a18fceeb30cceb3bae7d1427b9213" -dependencies = [ - "os_str_bytes", -] - [[package]] name = "futures" version = "0.3.21" @@ -194,12 +159,6 @@ dependencies = [ "slab", ] -[[package]] -name = "hashbrown" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" - [[package]] name = "hermit-abi" version = "0.1.19" @@ -209,16 +168,6 @@ dependencies = [ "libc", ] -[[package]] -name = "indexmap" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" -dependencies = [ - "autocfg", - "hashbrown", -] - [[package]] name = "indymilter" version = "0.1.1" @@ -312,12 +261,6 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b10983b38c53aebdf33f542c6275b0f58a238129d00c4ae0e6fb59738d783ca" -[[package]] -name = "os_str_bytes" -version = "6.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "029d8d0b2f198229de29dca79676f2738ff952edf3fde542eb8bf94d8c21b435" - [[package]] name = "pin-project-lite" version = "0.2.9" @@ -402,7 +345,6 @@ dependencies = [ "async-trait", "byte-strings", "chrono", - "clap", "futures", "indymilter", "ipnet", @@ -412,12 +354,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - [[package]] name = "syn" version = "1.0.95" @@ -429,21 +365,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "termcolor" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "textwrap" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" - [[package]] name = "time" version = "0.1.44" @@ -539,15 +460,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" -dependencies = [ - "winapi", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 9d398a1..c5f6e29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ exclude = ["/.gitignore", "/.gitlab-ci.yml"] async-trait = "0.1.52" byte-strings = "0.2.2" chrono = "0.4.19" -clap = "3.1.0" futures = "0.3.19" indymilter = "0.1.1" ipnet = "2.3.1" diff --git a/spamassassin-milter.8 b/spamassassin-milter.8 index e62708d..42837ef 100644 --- a/spamassassin-milter.8 +++ b/spamassassin-milter.8 @@ -15,8 +15,8 @@ spamassassin-milter \- milter for spam filtering with SpamAssassin .OP \-t NETS .OP \-v .I SOCKET -.RB [ \-\- -.IR SPAMC_ARGS ...] +.RB [ \-\- ] +.RI [ SPAMC_ARGS ...] .YS .SH DESCRIPTION .B spamassassin-milter @@ -42,10 +42,10 @@ or a UNIX domain socket in the form .BI unix: PATH (for example, .BR unix:/run/spamassassin-milter.sock ). -After the options and argument, additional arguments +After the options and argument, the remaining arguments .I SPAMC_ARGS -listed after -.B \-\- +(optionally preceded by +.BR \-\- ) are gathered as arguments to pass to the .B spamc invocation. @@ -73,7 +73,7 @@ messages are not processed with SpamAssassin. Process messages normally, but do not take action or apply any modifications. Combined with .BR \-\-verbose , -this gives accurate insight into what would happen if run without +this gives insight into what would happen if run without .BR \-\-dry-run . .TP .BR \-h ", " \-\-help @@ -154,8 +154,11 @@ Trust connections coming from the IP networks or addresses Connections from IP addresses contained in trusted networks are not processed with SpamAssassin. .I NETS -is a comma-separated list of IP network addresses, for example, +must be a comma-separated list of IP network addresses, for example, .BR ::1/128,127.0.0.0/8,192.168.1.39 . +If +.I NETS +is empty, no IP addresses are trusted. If this option is not used, all connections from loopback addresses are trusted. .TP .BR \-v ", " \-\-verbose diff --git a/src/main.rs b/src/main.rs index 4c97407..2cdf273 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ -use clap::{Arg, Command, ErrorKind}; use futures::stream::StreamExt; use indymilter::Listener; use signal_hook::consts::{SIGINT, SIGTERM}; use signal_hook_tokio::{Handle, Signals}; use spamassassin_milter::{Config, MILTER_NAME, VERSION}; -use std::{net::IpAddr, os::unix::fs::FileTypeExt, path::Path, process, str::FromStr}; +use std::{ + env, error::Error, net::IpAddr, os::unix::fs::FileTypeExt, path::Path, process, str::FromStr, +}; use tokio::{ fs, net::{TcpListener, UnixListener}, @@ -12,86 +13,13 @@ use tokio::{ task::JoinHandle, }; -const ARG_AUTH_UNTRUSTED: &str = "AUTH_UNTRUSTED"; -const ARG_DRY_RUN: &str = "DRY_RUN"; -const ARG_MAX_MESSAGE_SIZE: &str = "MAX_MESSAGE_SIZE"; -const ARG_PRESERVE_BODY: &str = "PRESERVE_BODY"; -const ARG_PRESERVE_HEADERS: &str = "PRESERVE_HEADERS"; -const ARG_REJECT_SPAM: &str = "REJECT_SPAM"; -const ARG_REPLY_CODE: &str = "REPLY_CODE"; -const ARG_REPLY_STATUS_CODE: &str = "REPLY_STATUS_CODE"; -const ARG_REPLY_TEXT: &str = "REPLY_TEXT"; -const ARG_TRUSTED_NETWORKS: &str = "TRUSTED_NETWORKS"; -const ARG_VERBOSE: &str = "VERBOSE"; -const ARG_SOCKET: &str = "SOCKET"; -const ARG_SPAMC_ARGS: &str = "SPAMC_ARGS"; - #[tokio::main] async fn main() { - let command = Command::new(MILTER_NAME) - .version(VERSION) - .arg(Arg::new(ARG_AUTH_UNTRUSTED) - .short('a') - .long("auth-untrusted") - .help("Treat authenticated senders as untrusted")) - .arg(Arg::new(ARG_DRY_RUN) - .short('n') - .long("dry-run") - .help("Process messages without applying changes")) - .arg(Arg::new(ARG_MAX_MESSAGE_SIZE) - .short('s') - .long("max-message-size") - .value_name("BYTES") - .help("Maximum message size to process")) - .arg(Arg::new(ARG_PRESERVE_BODY) - .short('B') - .long("preserve-body") - .help("Suppress rewriting of message body")) - .arg(Arg::new(ARG_PRESERVE_HEADERS) - .short('H') - .long("preserve-headers") - .help("Suppress rewriting of Subject/From/To headers")) - .arg(Arg::new(ARG_REJECT_SPAM) - .short('r') - .long("reject-spam") - .help("Reject messages flagged as spam")) - .arg(Arg::new(ARG_REPLY_CODE) - .short('C') - .long("reply-code") - .value_name("CODE") - .help("Reply code when rejecting messages")) - .arg(Arg::new(ARG_REPLY_STATUS_CODE) - .short('S') - .long("reply-status-code") - .value_name("CODE") - .help("Status code when rejecting messages")) - .arg(Arg::new(ARG_REPLY_TEXT) - .short('R') - .long("reply-text") - .value_name("MSG") - .help("Reply text when rejecting messages")) - .arg(Arg::new(ARG_TRUSTED_NETWORKS) - .short('t') - .long("trusted-networks") - .value_name("NETS") - .use_value_delimiter(true) - .help("Trust connections from these networks")) - .arg(Arg::new(ARG_VERBOSE) - .short('v') - .long("verbose") - .help("Enable verbose operation logging")) - .arg(Arg::new(ARG_SOCKET) - .required(true) - .help("Listening socket of the milter")) - .arg(Arg::new(ARG_SPAMC_ARGS) - .last(true) - .multiple_occurrences(true) - .help("Additional arguments to pass to spamc")); - - let (socket, config) = match build_config(command) { + let (socket, config) = match parse_args() { Ok(config) => config, Err(e) => { - e.exit(); + eprintln!("error: {}", e); + process::exit(1); } }; @@ -160,7 +88,7 @@ enum Socket { } impl FromStr for Socket { - type Err = (); + type Err = String; fn from_str(s: &str) -> Result { if let Some(s) = s.strip_prefix("inet:") { @@ -168,126 +96,164 @@ impl FromStr for Socket { } else if let Some(s) = s.strip_prefix("unix:") { Ok(Self::Unix(s.into())) } else { - Err(()) + Err(format!("invalid value for socket: \"{}\"", s)) } } } -fn build_config(mut command: Command) -> clap::Result<(Socket, Config)> { - let matches = command.get_matches_mut(); +const USAGE_TEXT: &str = "\ +USAGE: + spamassassin-milter [OPTIONS] [--] [...] - let socket = matches.value_of(ARG_SOCKET).unwrap(); - let socket = match socket.parse() { - Ok(socket) => socket, - Err(()) => { - return Err(command.error( - ErrorKind::InvalidValue, - format!("Invalid value for socket: \"{}\"", socket), - )); - } - }; +ARGUMENTS: + Listening socket of the milter + ... Additional arguments to pass to spamc + +OPTIONS: + -a, --auth-untrusted Treat authenticated senders as untrusted + -n, --dry-run Process messages without applying changes + -h, --help Print usage information + -s, --max-message-size Maximum message size to process + -B, --preserve-body Suppress rewriting of message body + -H, --preserve-headers Suppress rewriting of Subject/From/To headers + -r, --reject-spam Reject messages flagged as spam + -C, --reply-code Reply code when rejecting messages + -S, --reply-status-code Status code when rejecting messages + -R, --reply-text Reply text when rejecting messages + -t, --trusted-networks Trust connections from these networks + -v, --verbose Enable verbose operation logging + -V, --version Print version information +"; + +fn parse_args() -> Result<(Socket, Config), Box> { + let mut args = env::args_os() + .skip(1) + .map(|s| s.into_string().map_err(|_| "invalid UTF-8 bytes in argument")); let mut config = Config::builder(); - if let Some(bytes) = matches.value_of(ARG_MAX_MESSAGE_SIZE) { - match bytes.parse() { - Ok(bytes) => { + let mut reply_code = None; + let mut reply_status_code = None; + + let socket = loop { + let arg = args.next().ok_or("required argument missing")??; + + let missing_value = || format!("missing value for option {}", arg); + + match arg.as_str() { + "-h" | "--help" => { + println!("{} {}", MILTER_NAME, VERSION); + println!(); + print!("{}", USAGE_TEXT); + process::exit(0); + } + "-V" | "--version" => { + println!("{} {}", MILTER_NAME, VERSION); + process::exit(0); + } + "-a" | "--auth-untrusted" => { + config = config.auth_untrusted(true); + } + "-n" | "--dry-run" => { + config = config.dry_run(true); + } + "-B" | "--preserve-body" => { + config = config.preserve_body(true); + } + "-H" | "--preserve-headers" => { + config = config.preserve_headers(true); + } + "-r" | "--reject-spam" => { + config = config.reject_spam(true); + } + "-v" | "--verbose" => { + config = config.verbose(true); + } + "-s" | "--max-message-size" => { + let arg = args.next().ok_or_else(missing_value)??; + let bytes = arg.parse() + .map_err(|_| format!("invalid value for max message size: \"{}\"", arg))?; + config = config.max_message_size(bytes); } - Err(_) => { - return Err(command.error( - ErrorKind::InvalidValue, - format!("Invalid value for max message size: \"{}\"", bytes), - )); + "-C" | "--reply-code" => { + let code = args.next().ok_or_else(missing_value)??; + + reply_code = Some(code); } - } - } + "-S" | "--reply-status-code" => { + let code = args.next().ok_or_else(missing_value)??; - if let Some(nets) = matches.values_of(ARG_TRUSTED_NETWORKS) { - config = config.use_trusted_networks(true); + reply_status_code = Some(code); + } + "-R" | "--reply-text" => { + let msg = args.next().ok_or_else(missing_value)??; + + config = config.reply_text(msg); + } + "-t" | "--trusted-networks" => { + let arg = args.next().ok_or_else(missing_value)??; + + config = config.use_trusted_networks(true); + + for net in arg.split(',').filter(|n| !n.is_empty()) { + // Both `ipnet::IpNet` and `std::net::IpAddr` inputs are + // supported. + let net = net.parse() + .or_else(|_| net.parse::().map(From::from)) + .map_err(|_| format!("invalid value for trusted network address: \"{}\"", net))?; - for net in nets.filter(|n| !n.is_empty()) { - // Both `ipnet::IpNet` and `std::net::IpAddr` inputs are supported. - match net - .parse() - .or_else(|_| net.parse::().map(From::from)) - { - Ok(net) => { config = config.trusted_network(net); } - Err(_) => { - return Err(command.error( - ErrorKind::InvalidValue, - format!("Invalid value for trusted network address: \"{}\"", net), - )); - } } + arg => break arg.parse()?, } - } + }; - let reply_code = matches.value_of(ARG_REPLY_CODE); - let reply_status_code = matches.value_of(ARG_REPLY_STATUS_CODE); - validate_reply_codes(&mut command, reply_code, reply_status_code)?; + validate_reply_codes(reply_code.as_deref(), reply_status_code.as_deref())?; - if matches.is_present(ARG_AUTH_UNTRUSTED) { - config = config.auth_untrusted(true); - } - if matches.is_present(ARG_DRY_RUN) { - config = config.dry_run(true); - } - if matches.is_present(ARG_PRESERVE_BODY) { - config = config.preserve_body(true); - } - if matches.is_present(ARG_PRESERVE_HEADERS) { - config = config.preserve_headers(true); - } - if matches.is_present(ARG_REJECT_SPAM) { - config = config.reject_spam(true); - } - if matches.is_present(ARG_VERBOSE) { - config = config.verbose(true); - } if let Some(code) = reply_code { - config = config.reply_code(code.to_owned()); + config = config.reply_code(code); } if let Some(code) = reply_status_code { - config = config.reply_status_code(code.to_owned()); + config = config.reply_status_code(code); } - if let Some(msg) = matches.value_of(ARG_REPLY_TEXT) { - config = config.reply_text(msg.to_owned()); - } - if let Some(spamc_args) = matches.values_of(ARG_SPAMC_ARGS) { + + if let Some(arg) = args.next() { + let arg = arg?; + + let mut spamc_args = if arg == "--" { vec![] } else { vec![arg] }; + + for arg in args { + spamc_args.push(arg?); + } + config = config.spamc_args(spamc_args); - }; + } Ok((socket, config.build())) } fn validate_reply_codes( - command: &mut Command, reply_code: Option<&str>, reply_status_code: Option<&str>, -) -> clap::Result<()> { +) -> Result<(), Box> { match (reply_code, reply_status_code) { (Some(c1), Some(c2)) if !((c1.starts_with('4') || c1.starts_with('5')) && c2.starts_with(&c1[..1])) => { - Err(command.error( - ErrorKind::InvalidValue, - format!( - "Invalid or incompatible values for reply code and status code: \"{}\", \"{}\"", - c1, c2 - ), - )) + Err(format!( + "invalid or incompatible values for reply code and status code: \"{}\", \"{}\"", + c1, c2 + ) + .into()) + } + (Some(c), None) if !c.starts_with('5') => { + Err(format!("invalid value for reply code (5XX): \"{}\"", c).into()) + } + (None, Some(c)) if !c.starts_with('5') => { + Err(format!("invalid value for reply status code (5.X.X): \"{}\"", c).into()) } - (Some(c), None) if !c.starts_with('5') => Err(command.error( - ErrorKind::InvalidValue, - format!("Invalid value for reply code (5XX): \"{}\"", c), - )), - (None, Some(c)) if !c.starts_with('5') => Err(command.error( - ErrorKind::InvalidValue, - format!("Invalid value for reply status code (5.X.X): \"{}\"", c), - )), _ => Ok(()), } }