Drop clap

This commit is contained in:
David Bürgin 2022-07-18 09:04:13 +02:00
parent f72c075516
commit 931e59cab9
5 changed files with 150 additions and 259 deletions

View file

@ -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

88
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -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<Self, Self::Err> {
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] <SOCKET> [--] [<SPAMC_ARGS>...]
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:
<SOCKET> Listening socket of the milter
<SPAMC_ARGS>... 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 <BYTES> 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 <CODE> Reply code when rejecting messages
-S, --reply-status-code <CODE> Status code when rejecting messages
-R, --reply-text <MSG> Reply text when rejecting messages
-t, --trusted-networks <NETS> Trust connections from these networks
-v, --verbose Enable verbose operation logging
-V, --version Print version information
";
fn parse_args() -> Result<(Socket, Config), Box<dyn Error>> {
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) => {
config = config.max_message_size(bytes);
}
Err(_) => {
return Err(command.error(
ErrorKind::InvalidValue,
format!("Invalid value for max message size: \"{}\"", bytes),
));
}
}
}
let mut reply_code = None;
let mut reply_status_code = None;
if let Some(nets) = matches.values_of(ARG_TRUSTED_NETWORKS) {
config = config.use_trusted_networks(true);
let socket = loop {
let arg = args.next().ok_or("required argument <SOCKET> missing")??;
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::<IpAddr>().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),
));
}
}
}
}
let missing_value = || format!("missing value for option {}", arg);
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)?;
if matches.is_present(ARG_AUTH_UNTRUSTED) {
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);
}
if matches.is_present(ARG_DRY_RUN) {
"-n" | "--dry-run" => {
config = config.dry_run(true);
}
if matches.is_present(ARG_PRESERVE_BODY) {
"-B" | "--preserve-body" => {
config = config.preserve_body(true);
}
if matches.is_present(ARG_PRESERVE_HEADERS) {
"-H" | "--preserve-headers" => {
config = config.preserve_headers(true);
}
if matches.is_present(ARG_REJECT_SPAM) {
"-r" | "--reject-spam" => {
config = config.reject_spam(true);
}
if matches.is_present(ARG_VERBOSE) {
"-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);
}
"-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)??;
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::<IpAddr>().map(From::from))
.map_err(|_| format!("invalid value for trusted network address: \"{}\"", net))?;
config = config.trusted_network(net);
}
}
arg => break arg.parse()?,
}
};
validate_reply_codes(reply_code.as_deref(), reply_status_code.as_deref())?;
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(arg) = args.next() {
let arg = arg?;
let mut spamc_args = if arg == "--" { vec![] } else { vec![arg] };
for arg in args {
spamc_args.push(arg?);
}
if let Some(spamc_args) = matches.values_of(ARG_SPAMC_ARGS) {
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<dyn Error>> {
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: \"{}\", \"{}\"",
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(()),
}
}