Merge branch 'master' into synth-relay

This commit is contained in:
David Bürgin 2022-03-08 19:50:22 +01:00
commit 472145ee27
35 changed files with 1455 additions and 904 deletions

View file

@ -1,7 +1,7 @@
image: rust
before_script:
- apt-get --assume-yes update
- apt-get --assume-yes install pkg-config libmilter-dev spamc miltertest
- apt-get --assume-yes install spamc miltertest
test:
script:
- cargo test --verbose

View file

@ -1,72 +1,95 @@
# SpamAssassin Milter changelog
## 0.2.2 (unreleased)
## 0.3.1 (unreleased)
* Add `--synth-relay` option to allow inserting the synthesised internal relay
header (the MTAs `Received` header) at a different position than at the
very beginning.
### Added
* Add `--synth-relay` option to allow inserting the synthesised internal relay
header (the MTAs `Received` header) at a different position than at the very
beginning.
## 0.3.0 (2022-03-08)
In this release, the milter implementation has been replaced with the new
[indymilter] library. With this change, there is no longer a dependency on the
libmilter C library. SpamAssassin Milter is now a pure Rust application.
The minimum supported Rust version is now 1.56.1.
### Changed
* The minimum supported Rust version is now 1.56.1 (using Rust edition 2021).
* The syntax of the mandatory `SOCKET` argument has changed:
- Use <code>inet:<em>host</em>:<em>port</em></code> for a TCP socket.
- Use <code>unix:<em>path</em></code> for a UNIX domain socket.
* The command-line help information has changed its appearance slightly with the
update of the underlying [clap] CLI library.
* The changelog is now maintained in a more structured format, similar to
https://keepachangelog.com.
[indymilter]: https://crates.io/crates/indymilter
[clap]: https://crates.io/crates/clap
## 0.2.1 (2021-12-23)
* Various cosmetic improvements in code and tests, and updates to
documentation.
* Update dependencies.
* Various cosmetic improvements in code and tests, and updates to documentation.
* Update dependencies.
## 0.2.0 (2021-08-26)
* Bump minimum supported Rust version to 1.46.0.
* (defaults change) Invoke `spamc` using the absolute path `/usr/bin/spamc`
(instead of any executable named `spamc` in the search path). To customise
this, set the environment variable `SPAMASSASSIN_MILTER_SPAMC` to the
desired path when building the application.
* Revise header rewriting logic. Handling and placement of `X-Spam-` headers
now more accurately mirrors that applied by SpamAssassin.
* Include authentication status in information passed on to SpamAssassin.
* Update dependencies.
* Bump minimum supported Rust version to 1.46.0.
* (defaults change) Invoke `spamc` using the absolute path `/usr/bin/spamc`
(instead of any executable named `spamc` in the search path). To customise
this, set the environment variable `SPAMASSASSIN_MILTER_SPAMC` to the desired
path when building the application.
* Revise header rewriting logic. Handling and placement of `X-Spam-` headers now
more accurately mirrors that applied by SpamAssassin.
* Include authentication status in information passed on to SpamAssassin.
* Update dependencies.
## 0.1.6 (2021-05-17)
* Improve processing of incoming `X-Spam-Flag` headers. Previously, in rare
circumstances a message flagged as spam would not be rejected as requested.
Reported by Petar Bogdanovic.
* Update dependencies.
* Improve processing of incoming `X-Spam-Flag` headers. Previously, in rare
circumstances a message flagged as spam would not be rejected as requested.
Reported by Petar Bogdanovic.
* Update dependencies.
## 0.1.5 (2021-03-16)
* Read output from `spamc` in a separate thread in order to avoid blocking
when processing large messages in certain configurations.
* Document requirement to keep `--max-message-size` setting in sync with
`spamc`s `--max-size` setting.
* Remove overly strict validation of command-line options.
* Properly specify minimal dependency versions in `Cargo.toml`.
* Document minimum supported Rust version 1.42.0.
* Read output from `spamc` in a separate thread in order to avoid blocking when
processing large messages in certain configurations.
* Document requirement to keep `--max-message-size` setting in sync with
`spamc`s `--max-size` setting.
* Remove overly strict validation of command-line options.
* Properly specify minimal dependency versions in `Cargo.toml`.
* Document minimum supported Rust version 1.42.0.
## 0.1.4 (2020-10-18)
* Correct a typo in log messages.
* Isolate integration tests from any existing `spamc` configuration present on
the host.
* Various à la mode style improvements in code and project metadata.
* Correct a typo in log messages.
* Isolate integration tests from any existing `spamc` configuration present on
the host.
* Various à la mode style improvements in code and project metadata.
## 0.1.3 (2020-07-04)
* Add `--reply-code`, `--reply-status-code`, and `--reply-text` options to
allow customising the SMTP reply when rejecting spam.
* Log a warning and do not truncate the message body when `--max-message-size`
is misconfigured (must be ≥ `spamc` max size as documented).
* Update dependencies in `Cargo.lock`.
* Add `--reply-code`, `--reply-status-code`, and `--reply-text` options to allow
customising the SMTP reply when rejecting spam.
* Log a warning and do not truncate the message body when `--max-message-size`
is misconfigured (must be ≥ `spamc` max size as documented).
* Update dependencies in `Cargo.lock`.
## 0.1.2 (2020-06-07)
* Bump milter dependency to version 0.2.1.
* Remove existing UNIX domain socket at target path during startup.
* Derive `Eq` and `PartialEq` for configuration structs.
* Bump milter dependency to version 0.2.1.
* Remove existing UNIX domain socket at target path during startup.
* Derive `Eq` and `PartialEq` for configuration structs.
## 0.1.1 (2020-04-13)
* Use `Write::write_all` instead of `Write::write` in `spamc` client, in order
to ensure buffers are written in their entirety.
* Do not include `.gitignore` file in published crate.
* Use `Write::write_all` instead of `Write::write` in `spamc` client, in order
to ensure buffers are written in their entirety.
* Do not include `.gitignore` file in published crate.
## 0.1.0 (2020-02-23)

414
Cargo.lock generated
View file

@ -12,12 +12,14 @@ dependencies = [
]
[[package]]
name = "ansi_term"
version = "0.12.1"
name = "async-trait"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
checksum = "061a7acccaa286c011ddc30970520b98fa40e00c9d644633fb26b5fc63a265e3"
dependencies = [
"winapi",
"proc-macro2",
"quote",
"syn",
]
[[package]]
@ -33,9 +35,9 @@ dependencies = [
[[package]]
name = "autocfg"
version = "1.0.1"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "bitflags"
@ -43,6 +45,38 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "byte-strings"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "963ceed6e0041e1f4cdd9e2fae3b384f5613a22119b5bb04ccc14fc815e87ae3"
dependencies = [
"byte-strings-proc_macros",
]
[[package]]
name = "byte-strings-proc_macros"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e78e8673d97234c7a07636474b02c92fad06a0f26f70581aa46aee124c508e5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "bytes"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.19"
@ -58,19 +92,114 @@ dependencies = [
[[package]]
name = "clap"
version = "2.34.0"
version = "3.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
checksum = "5177fac1ab67102d8989464efd043c6ff44191b1557ec1ddd489b4f7e1447e77"
dependencies = [
"ansi_term",
"atty",
"bitflags",
"indexmap",
"os_str_bytes",
"strsim",
"termcolor",
"textwrap",
"unicode-width",
"vec_map",
]
[[package]]
name = "futures"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3"
[[package]]
name = "futures-executor"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b"
[[package]]
name = "futures-macro"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868"
[[package]]
name = "futures-task"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a"
[[package]]
name = "futures-util"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"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"
@ -80,6 +209,29 @@ dependencies = [
"libc",
]
[[package]]
name = "indexmap"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223"
dependencies = [
"autocfg",
"hashbrown",
]
[[package]]
name = "indymilter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8af90f5b5335bfceaf2fd7b85143f2ab9ff0f71fc062bbdb9a5ae432cc135368"
dependencies = [
"async-trait",
"bitflags",
"bytes",
"tokio",
"tracing",
]
[[package]]
name = "ipnet"
version = "2.3.1"
@ -87,10 +239,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9"
[[package]]
name = "libc"
version = "0.2.112"
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b03d17f364a3a042d5e5d46b053bbbf82c92c9430c592dd4c064dc6ee997125"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf2e165bb3457c8e098ea76f3e3bc9db55f87aa90d52d0e6be741470916aaa4"
[[package]]
name = "log"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
dependencies = [
"cfg-if",
]
[[package]]
name = "memchr"
@ -99,37 +266,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a"
[[package]]
name = "milter"
version = "0.2.4"
name = "mio"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8303a6ac7b50962cb1a24ea9f35fd336dc680a628e167962adee4a9a1babf3"
checksum = "ba272f85fa0b41fc91872be579b3bbe0f56b792aa361a380eb669469f68dafb2"
dependencies = [
"bitflags",
"libc",
"milter-callback",
"milter-sys",
"once_cell",
"log",
"miow",
"ntapi",
"winapi",
]
[[package]]
name = "milter-callback"
version = "0.2.4"
name = "miow"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6288a38fe087aff33e732c50d4eb05a782a19eef0f29eaf1f9b931ba9ef37f1e"
checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21"
dependencies = [
"proc-macro2",
"quote",
"syn",
"winapi",
]
[[package]]
name = "milter-sys"
version = "0.2.3"
name = "ntapi"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46d0d54709e88c3b120d9c7bc90555bd5a1584c19f78ca15cd262d4de7faf34c"
checksum = "c28774a7fd2fbb4f0babd8237ce554b73af68021b5f695a3cebd6c59bac0980f"
dependencies = [
"libc",
"pkg-config",
"winapi",
]
[[package]]
@ -151,6 +315,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "once_cell"
version = "1.9.0"
@ -158,25 +332,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da32515d9f6e6e489d7bc9d84c71b060db7247dc035bbe44eac88cf87486d8d5"
[[package]]
name = "pkg-config"
version = "0.3.24"
name = "os_str_bytes"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe"
checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64"
dependencies = [
"memchr",
]
[[package]]
name = "pin-project-lite"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e280fbe77cc62c91527259e9442153f4688736748d24660126286329742b4c6c"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "proc-macro2"
version = "1.0.34"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f84e92c0f7c9d58328b85a78557813e4bd845130db68d7184635344399423b1"
checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029"
dependencies = [
"unicode-xid",
]
[[package]]
name = "quote"
version = "1.0.10"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05"
checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145"
dependencies = [
"proc-macro2",
]
@ -199,29 +388,81 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
[[package]]
name = "spamassassin-milter"
version = "0.2.1"
name = "signal-hook"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "647c97df271007dcea485bb74ffdb57f2e683f1306c854f468a0c244badabf2d"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0"
dependencies = [
"libc",
]
[[package]]
name = "signal-hook-tokio"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "213241f76fb1e37e27de3b6aa1b068a2c333233b59cca6634f634b80a27ecf1e"
dependencies = [
"futures-core",
"libc",
"signal-hook",
"tokio",
]
[[package]]
name = "slab"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5"
[[package]]
name = "socket2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "spamassassin-milter"
version = "0.3.0"
dependencies = [
"async-trait",
"byte-strings",
"chrono",
"clap",
"futures",
"indymilter",
"ipnet",
"libc",
"milter",
"once_cell",
"regex",
"signal-hook",
"signal-hook-tokio",
"tokio",
]
[[package]]
name = "strsim"
version = "0.8.0"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "syn"
version = "1.0.82"
version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8daf5dd0bb60cbd4137b1b587d2fc0ae729bc07cf01cd70b36a1ed5ade3b9d59"
checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b"
dependencies = [
"proc-macro2",
"quote",
@ -229,14 +470,20 @@ dependencies = [
]
[[package]]
name = "textwrap"
version = "0.11.0"
name = "termcolor"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4"
dependencies = [
"unicode-width",
"winapi-util",
]
[[package]]
name = "textwrap"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80"
[[package]]
name = "time"
version = "0.1.44"
@ -249,10 +496,54 @@ dependencies = [
]
[[package]]
name = "unicode-width"
version = "0.1.9"
name = "tokio"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973"
checksum = "2af73ac49756f3f7c01172e34a23e5d0216f6c32333757c2c61feb2bbff5a5ee"
dependencies = [
"bytes",
"libc",
"memchr",
"mio",
"num_cpus",
"once_cell",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"winapi",
]
[[package]]
name = "tokio-macros"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b557f72f448c511a979e2564e55d74e6c4432fc96ff4f6241bc6bded342643b7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6c650a8ef0cd2dd93736f033d21cbd1224c5a967aa0c258d00fcf7dafef9b9f"
dependencies = [
"cfg-if",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03cfcb51380632a72d3111cb8d3447a8d908e577d31beeac006f836383d29a23"
dependencies = [
"lazy_static",
]
[[package]]
name = "unicode-xid"
@ -260,12 +551,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "wasi"
version = "0.10.0+wasi-snapshot-preview1"
@ -288,6 +573,15 @@ 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

@ -1,8 +1,8 @@
[package]
name = "spamassassin-milter"
version = "0.2.1"
edition = "2018"
rust-version = "1.46.0"
version = "0.3.0"
edition = "2021"
rust-version = "1.56.1"
description = "Milter for spam filtering with SpamAssassin"
license = "GPL-3.0-or-later"
categories = ["email"]
@ -11,10 +11,18 @@ repository = "https://gitlab.com/glts/spamassassin-milter"
exclude = ["/.gitignore", "/.gitlab-ci.yml"]
[dependencies]
chrono = "0.4.10"
clap = "2.33"
ipnet = "2.2"
libc = "0.2.66"
milter = "0.2.3"
once_cell = "1.3"
regex = "1.4"
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.0"
ipnet = "2.3.1"
once_cell = "1.9.0"
regex = "1.5.4"
signal-hook = "0.3.13"
signal-hook-tokio = { version = "0.3.0", features = ["futures-v0_3"] }
tokio = { version = "1.15.0", features = ["fs", "io-util", "macros", "net", "process", "rt", "rt-multi-thread", "sync"] }
[dev-dependencies]
tokio = { version = "1.15.0", features = ["signal", "time"] }

View file

@ -5,7 +5,7 @@ SpamAssassin server using the `spamc` client. It is a light-weight component
that serves to integrate [Apache SpamAssassin] with a milter-capable MTA (mail
server) such as [Postfix]. Its task is thus helping combat spam on email sites.
SpamAssassin Milter operates as a [milter] hooked into the MTAs SMTP protocol
SpamAssassin Milter operates as a milter hooked into the MTAs SMTP protocol
handler. It passes incoming messages to SpamAssassin for analysis, and then
interprets the response from SpamAssassin and applies suggested changes to the
message.
@ -32,7 +32,6 @@ Postfix, and for delivery [Dovecot] with LMTP and the [Sieve plugin].
[Apache SpamAssassin]: https://spamassassin.apache.org
[Postfix]: http://www.postfix.org
[milter]: https://crates.io/crates/milter
[spamass-milter]: https://savannah.nongnu.org/projects/spamass-milt
[Dovecot]: https://dovecot.org
[Sieve plugin]: https://doc.dovecot.org/configuration_manual/sieve
@ -46,24 +45,12 @@ usual:
cargo install --locked spamassassin-milter
```
As a milter, this package requires the libmilter C library to be available. Be
sure to install the libmilter shared library provided by your distribution.
The shared library is discovered using the pkg-config program. If your
distribution does not install pkg-config metadata for libmilter, try using the
provided `milter.pc` file. Put this file on the pkg-config path when running any
Cargo command:
```
PKG_CONFIG_PATH=/path/to/milter.pc cargo build
```
SpamAssassin Milter uses the `spamc` program for communication with SpamAssassin
server. By default, `/usr/bin/spamc` is used as the executable. To override
this, set the environment variable `SPAMASSASSIN_MILTER_SPAMC` to the desired
path when building the application.
The minimum supported Rust version is 1.46.0.
The minimum supported Rust version is 1.56.1.
[Rust]: https://www.rust-lang.org
@ -83,14 +70,13 @@ use an up-to-date version of `miltertest`.)
Once installed, SpamAssassin Milter can be invoked as `spamassassin-milter`.
`spamassassin-milter` takes one mandatory argument, namely the listening socket
of the milter (the socket to which the MTA will connect). The socket spec should
be in one of the formats <code>inet:<em>port</em>@<em>host</em></code> or
<code>inet6:<em>port</em>@<em>host</em></code> (for IPv6), or
be in one of the formats <code>inet:<em>host</em>:<em>port</em></code> or
<code>unix:<em>path</em></code>, for a TCP or UNIX domain socket, respectively.
For example, the following invocation starts SpamAssassin Milter on port 3000:
```
spamassassin-milter inet:3000@localhost
spamassassin-milter inet:localhost:3000
```
The available options can be glimpsed by passing the `-h` flag:
@ -126,7 +112,7 @@ going live. Combined with `--verbose`, this gives accurate insight into the
changes that SpamAssassin Milter would apply.
```
spamassassin-milter --dry-run --verbose inet:3000@localhost
spamassassin-milter --dry-run --verbose inet:localhost:3000
```
## Integration with SpamAssassin
@ -272,7 +258,7 @@ messages with score 5.0 or above into Junk.
## Licence
Copyright © 20202021 David Bürgin
Copyright © 20202022 David Bürgin
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software

View file

@ -1,7 +0,0 @@
prefix=/usr
libdir=${prefix}/lib/x86_64-linux-gnu
Name: milter
Description: Sendmail Mail Filter API (Milter)
Version: 8.15.2
Libs: -L${libdir} -lmilter

View file

@ -1,4 +1,4 @@
.TH SPAMASSASSIN-MILTER 8 2021-12-23
.TH SPAMASSASSIN-MILTER 8 2022-03-08
.SH NAME
spamassassin-milter \- milter for spam filtering with SpamAssassin
.SH SYNOPSIS
@ -33,11 +33,9 @@ The mandatory
argument specifies the listening socket to open.
.I SOCKET
can be either an IPv4/IPv6 TCP socket in the form
.BI inet: PORT @ HOST
or
.BI inet6: PORT @ HOST
.BI inet: HOST : PORT
(for example,
.BR inet:3000@localhost ),
.BR inet:localhost:3000 )
or a UNIX domain socket in the form
.BI unix: PATH
(for example,

View file

@ -1,10 +1,11 @@
[Unit]
Description=SpamAssassin Milter
Documentation=man:spamassassin-milter(8)
After=network.target
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/sbin/spamassassin-milter inet:3000@localhost
ExecStart=/usr/sbin/spamassassin-milter inet:localhost:3000
Restart=on-failure
[Install]

View file

@ -1,12 +1,35 @@
use crate::{
client::{Client, Spamc},
config::{self, SynthRelayPosition},
client::{Client, ReceivedInfo, Spamc},
config::{Config, SynthRelayPosition},
error::Result,
};
use byte_strings::c_str;
use chrono::Local;
use milter::{Actions, DataHandle, MacroValue, ProtocolOpts, Stage, Status};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use indymilter::{
Actions, Callbacks, Context, EomContext, Macros, NegotiateContext, ProtoOpts, SocketInfo,
Stage, Status,
};
use std::{
borrow::Cow,
ffi::{CStr, CString},
net::{IpAddr, Ipv4Addr},
sync::Arc,
};
struct Connection {
// The send operations all may fail with an I/O error. These are logged and
// answered with `Status::Tempfail`. We dont expect this to happen in normal
// circumstances, only when something is wrong with `spamc` configuration or
// operation.
macro_rules! ok_or_tempfail {
($expr:expr) => {
if let ::std::result::Result::Err(e) = $expr {
::std::eprintln!("failed to communicate with spamc: {}", e);
return ::indymilter::Status::Tempfail;
}
};
}
pub struct Connection {
client_ip: IpAddr,
helo_host: Option<String>,
client: Option<Client>,
@ -26,231 +49,280 @@ trait ConnectionMut {
fn connection(&mut self) -> &mut Connection;
}
impl ConnectionMut for DataHandle<Connection> {
impl ConnectionMut for Option<Connection> {
fn connection(&mut self) -> &mut Connection {
self.borrow_mut().expect("milter context data not available")
self.as_mut().expect("milter context data not available")
}
}
type Context = milter::Context<Connection>;
trait MacrosExt {
fn get_string(&self, name: &CStr) -> Option<Cow<'_, str>>;
fn queue_id(&self) -> Cow<'_, str>;
}
#[milter::on_negotiate(negotiate_callback)]
fn handle_negotiate(
context: Context,
actions: Actions,
protocol_opts: ProtocolOpts,
) -> milter::Result<(Status, Actions, ProtocolOpts)> {
let mut req_actions = Actions::empty();
if !config::get().dry_run() {
req_actions |= Actions::ADD_HEADER | Actions::REPLACE_HEADER;
if !config::get().preserve_body() {
req_actions |= Actions::REPLACE_BODY;
impl MacrosExt for Macros {
fn get_string(&self, name: &CStr) -> Option<Cow<'_, str>> {
self.get(name).map(|v| v.to_string_lossy())
}
fn queue_id(&self) -> Cow<'_, str> {
self.get_string(c_str!("i"))
.unwrap_or_else(|| "NONE".into())
}
}
pub fn make_callbacks(config: Config) -> Callbacks<Connection> {
let config = Arc::new(config);
let config_connect = config.clone();
let config_mail = config.clone();
let config_data = config.clone();
let config_header = config.clone();
let config_eoh = config.clone();
let config_body = config.clone();
let config_eom = config.clone();
Callbacks::new()
.on_negotiate(move |cx, _, _| Box::pin(handle_negotiate(config.clone(), cx)))
.on_connect(move |cx, _, socket_info| {
Box::pin(handle_connect(config_connect.clone(), cx, socket_info))
})
.on_helo(|cx, helo_host| Box::pin(handle_helo(cx, helo_host)))
.on_mail(move |cx, smtp_args| Box::pin(handle_mail(config_mail.clone(), cx, smtp_args)))
.on_rcpt(|cx, smtp_args| Box::pin(handle_rcpt(cx, smtp_args)))
.on_data(move |cx| Box::pin(handle_data(config_data.clone(), cx)))
.on_header(move |cx, name, value| {
Box::pin(handle_header(config_header.clone(), cx, name, value))
})
.on_eoh(move |cx| Box::pin(handle_eoh(config_eoh.clone(), cx)))
.on_body(move |cx, chunk| Box::pin(handle_body(config_body.clone(), cx, chunk)))
.on_eom(move |cx| Box::pin(handle_eom(config_eom.clone(), cx)))
.on_abort(|cx| Box::pin(handle_abort(cx)))
.on_close(|cx| Box::pin(handle_close(cx)))
}
async fn handle_negotiate(
config: Arc<Config>,
context: &mut NegotiateContext<Connection>,
) -> Status {
if !config.dry_run() {
context.requested_actions |= Actions::ADD_HEADER | Actions::CHANGE_HEADER;
if !config.preserve_body() {
context.requested_actions |= Actions::REPLACE_BODY;
}
}
let req_protocol_opts =
ProtocolOpts::NO_UNKNOWN | ProtocolOpts::SKIP | ProtocolOpts::HEADER_LEADING_SPACE;
context.requested_opts |= ProtoOpts::SKIP | ProtoOpts::HEADER_LEADING_SPACE;
assert!(actions.contains(req_actions), "required milter actions not supported");
assert!(protocol_opts.contains(req_protocol_opts), "required milter protocol options not supported");
context.api.request_macros(Stage::Connect, "")?;
context.api.request_macros(Stage::Helo, "")?;
context.api.request_macros(Stage::Mail, "{auth_authen}")?;
context.api.request_macros(Stage::Rcpt, "")?;
context.api.request_macros(Stage::Data, "i j _ {tls_version} v")?;
context.api.request_macros(Stage::Eoh, "")?;
context.api.request_macros(Stage::Eom, "")?;
Ok((Status::Continue, req_actions, req_protocol_opts))
}
#[milter::on_connect(connect_callback)]
fn handle_connect(
mut context: Context,
_: &str,
socket_addr: Option<SocketAddr>,
) -> milter::Result<Status> {
let ip = socket_addr.map_or(IpAddr::V4(Ipv4Addr::LOCALHOST), |a| a.ip());
if config::get().use_trusted_networks() {
if config::get().is_in_trusted_networks(&ip) {
verbose!("accepted connection from trusted network address {}", ip);
return Ok(Status::Accept);
}
} else if ip.is_loopback() {
verbose!("accepted local connection");
return Ok(Status::Accept);
}
let conn = Connection::new(ip);
context.data.replace(conn)?;
Ok(Status::Continue)
}
#[milter::on_helo(helo_callback)]
fn handle_helo(mut context: Context, helo_host: &str) -> Status {
let conn = context.data.connection();
conn.helo_host = Some(helo_host.to_owned());
context.requested_macros.insert(Stage::Mail, c_str!("{auth_authen}").into());
context.requested_macros.insert(Stage::Data, c_str!("i j _ {tls_version} v").into());
Status::Continue
}
#[milter::on_mail(mail_callback)]
fn handle_mail(mut context: Context, smtp_args: Vec<&str>) -> milter::Result<Status> {
async fn handle_connect(
config: Arc<Config>,
context: &mut Context<Connection>,
socket_info: SocketInfo,
) -> Status {
let ip = match socket_info {
SocketInfo::Inet(addr) => addr.ip(),
_ => IpAddr::V4(Ipv4Addr::LOCALHOST),
};
if config.use_trusted_networks() {
if config.is_in_trusted_networks(&ip) {
verbose!(config, "accepted connection from trusted network address {}", ip);
return Status::Accept;
}
} else if ip.is_loopback() {
verbose!(config, "accepted local connection");
return Status::Accept;
}
context.data = Some(Connection::new(ip));
Status::Continue
}
async fn handle_helo(context: &mut Context<Connection>, helo_host: CString) -> Status {
let conn = context.data.connection();
if !config::get().auth_untrusted() {
if let Some(login) = context.api.macro_value("{auth_authen}")? {
verbose!("accepted message from sender authenticated as \"{}\"", login);
return Ok(Status::Accept);
let helo_host = helo_host.to_string_lossy();
conn.helo_host = Some(helo_host.into());
Status::Continue
}
async fn handle_mail(
config: Arc<Config>,
context: &mut Context<Connection>,
smtp_args: Vec<CString>,
) -> Status {
if !config.auth_untrusted() {
if let Some(login) = context.macros.get_string(c_str!("{auth_authen}")) {
verbose!(config, "accepted message from sender authenticated as \"{}\"", login);
return Status::Accept;
}
}
let spamc = Spamc::new(config::get().spamc_args());
let sender = smtp_args[0].to_owned();
let conn = context.data.connection();
let spamc = Spamc::new(config.spamc_args());
let sender = smtp_args[0].to_string_lossy();
conn.client = Some(Client::new(
spamc,
conn.client_ip,
conn.helo_host.as_deref(),
sender,
sender.into(),
));
Ok(Status::Continue)
}
#[milter::on_rcpt(rcpt_callback)]
fn handle_rcpt(mut context: Context, smtp_args: Vec<&str>) -> Status {
let conn = context.data.connection();
let client = conn.client.as_mut().unwrap();
let recipient = smtp_args[0].to_owned();
client.add_recipient(recipient);
Status::Continue
}
#[milter::on_data(data_callback)]
fn handle_data(mut context: Context) -> milter::Result<Status> {
async fn handle_rcpt(context: &mut Context<Connection>, smtp_args: Vec<CString>) -> Status {
let conn = context.data.connection();
let client = conn.client.as_mut().unwrap();
let id = queue_id(&context.api)?;
let recipient = smtp_args[0].to_string_lossy();
client.add_recipient(recipient.into());
Status::Continue
}
async fn handle_data(config: Arc<Config>, context: &mut Context<Connection>) -> Status {
let conn = context.data.connection();
let client = conn.client.as_mut().unwrap();
let id = context.macros.queue_id();
if let Err(e) = client.connect() {
eprintln!("{}: failed to start spamc: {}", id, e);
return Ok(Status::Tempfail);
return Status::Tempfail;
}
if config::get().synth_relay().is_none() {
synthesize_relay_header(&context.api, client)?;
if config.synth_relay().is_none() {
ok_or_tempfail!(synthesize_relay_header(&config, &context.macros, client).await);
}
Ok(Status::Continue)
Status::Continue
}
#[milter::on_header(header_callback)]
fn handle_header(mut context: Context, name: &str, value: &str) -> milter::Result<Status> {
async fn handle_header(
config: Arc<Config>,
context: &mut Context<Connection>,
name: CString,
value: CString,
) -> Status {
let conn = context.data.connection();
let client = conn.client.as_mut().unwrap();
if let Some(pos) = config::get().synth_relay() {
let name = name.to_string_lossy();
let value = value.to_string_lossy();
if let Some(pos) = config.synth_relay() {
if !client.is_synth_relay_inserted() {
match pos {
SynthRelayPosition::Index(i) => {
if *i == client.header_count() {
synthesize_relay_header(&context.api, client)?;
ok_or_tempfail!(synthesize_relay_header(&config, &context.macros, client).await);
}
}
SynthRelayPosition::Regex(regex) => {
if !regex.is_match(&format!("{}:{}", name, value)) {
synthesize_relay_header(&context.api, client)?;
ok_or_tempfail!(synthesize_relay_header(&config, &context.macros, client).await);
}
}
}
}
}
client.send_header(name, value)?;
ok_or_tempfail!(client.send_header(&name, &value).await);
Ok(Status::Continue)
Status::Continue
}
#[milter::on_eoh(eoh_callback)]
fn handle_eoh(mut context: Context) -> milter::Result<Status> {
async fn handle_eoh(config: Arc<Config>, context: &mut Context<Connection>) -> Status {
let conn = context.data.connection();
let client = conn.client.as_mut().unwrap();
if config::get().synth_relay().is_some() && !client.is_synth_relay_inserted() {
synthesize_relay_header(&context.api, client)?;
if config.synth_relay().is_some() && !client.is_synth_relay_inserted() {
ok_or_tempfail!(synthesize_relay_header(&config, &context.macros, client).await);
}
client.send_eoh()?;
ok_or_tempfail!(client.send_eoh().await);
Ok(Status::Continue)
Status::Continue
}
fn synthesize_relay_header(api: &impl MacroValue, client: &mut Client) -> milter::Result<()> {
let id = queue_id(api)?;
async fn synthesize_relay_header(config: &Config, macros: &Macros, client: &mut Client) -> Result<()> {
let id = macros.queue_id();
if config::get().synth_relay().is_some() {
verbose!("{}: inserting synthetic relay header at index {}", id, client.header_count());
if config.synth_relay().is_some() {
verbose!(config, "{}: inserting synthetic relay header at index {}", id, client.header_count());
}
// Note that when SpamAssassin reports are enabled (`report_safe 1`), the
// synthesised headers below are leaked to users in the sense that they
// are included inside the email MIME attachment in the new message body.
client.send_envelope_sender()?;
client.send_envelope_recipients()?;
client.send_synthesized_received_header(
api.macro_value("_")?,
api.macro_value("j")?.unwrap_or("localhost"),
api.macro_value("v")?
.and_then(|v| v.split_ascii_whitespace().next())
.unwrap_or("Postfix"),
api.macro_value("{tls_version}")?.is_some(),
api.macro_value("{auth_authen}")?.is_some(),
id,
&Local::now().to_rfc2822(),
)?;
client.send_envelope_sender().await?;
client.send_envelope_recipients().await?;
let info = ReceivedInfo {
client_name_addr: macros.get_string(c_str!("_")),
my_hostname: macros.get_string(c_str!("j")),
mta: macros.get_string(c_str!("v")),
tls: macros.get_string(c_str!("{tls_version}")),
auth: macros.get_string(c_str!("{auth_authen}")),
queue_id: &id,
date_time: Local::now().to_rfc2822(),
};
client.send_synthesized_received_header(info).await?;
Ok(())
}
#[milter::on_body(body_callback)]
fn handle_body(mut context: Context, bytes: &[u8]) -> milter::Result<Status> {
async fn handle_body(
config: Arc<Config>,
context: &mut Context<Connection>,
chunk: Vec<u8>,
) -> Status {
let conn = context.data.connection();
let client = conn.client.as_mut().unwrap();
client.send_body_chunk(bytes)?;
ok_or_tempfail!(client.send_body_chunk(&chunk).await);
let max = config::get().max_message_size();
Ok(if client.bytes_written() > max {
let id = queue_id(&context.api)?;
verbose!("{}: skipping rest of message larger than {} bytes", id, max);
let max = config.max_message_size();
if client.bytes_written() > max {
let id = context.macros.queue_id();
verbose!(config, "{}: skipping rest of message larger than {} bytes", id, max);
client.skip_body();
Status::Skip
} else {
Status::Continue
})
}
}
#[milter::on_eom(eom_callback)]
fn handle_eom(mut context: Context) -> milter::Result<Status> {
async fn handle_eom(config: Arc<Config>, context: &mut EomContext<Connection>) -> Status {
let conn = context.data.connection();
let client = conn.client.take().unwrap();
let id = queue_id(&context.api)?;
let config = config::get();
let id = context.macros.queue_id();
client.process(id, &context.api, config)
match client.process(&id, &mut context.reply, &context.actions, &config).await {
Ok(status) => status,
Err(e) => {
eprintln!("{}: failed to process message: {}", id, e);
Status::Tempfail
}
}
}
#[milter::on_abort(abort_callback)]
fn handle_abort(mut context: Context) -> Status {
async fn handle_abort(context: &mut Context<Connection>) -> Status {
let conn = context.data.connection();
conn.client = None;
@ -258,13 +330,8 @@ fn handle_abort(mut context: Context) -> Status {
Status::Continue
}
#[milter::on_close(close_callback)]
fn handle_close(mut context: Context) -> milter::Result<Status> {
context.data.take()?;
async fn handle_close(context: &mut Context<Connection>) -> Status {
context.data = None;
Ok(Status::Continue)
}
fn queue_id(macros: &impl MacroValue) -> milter::Result<&str> {
macros.macro_value("i").map(|i| i.unwrap_or("NONE"))
Status::Continue
}

View file

@ -3,25 +3,28 @@ use crate::{
email::{self, Email, HeaderMap, HeaderRewriter},
error::{Error, Result},
};
use milter::{ActionContext, SetErrorReply, Status};
use async_trait::async_trait;
use indymilter::{ContextActions, SetErrorReply, Status};
use std::{
any::Any,
io::{self, Read, Write},
net::IpAddr,
os::unix::process::ExitStatusExt,
process::{Child, Command, Stdio},
thread::{self, JoinHandle},
any::Any, borrow::Cow, io, net::IpAddr, os::unix::process::ExitStatusExt, pin::Pin,
process::Stdio,
};
use tokio::{
io::{AsyncReadExt, AsyncWrite, AsyncWriteExt},
process::{Child, Command},
task::JoinHandle,
};
#[async_trait]
pub trait Process {
fn connect(&mut self) -> Result<()>;
fn writer(&mut self) -> &mut dyn Write;
fn finish(&mut self) -> Result<Vec<u8>>;
fn writer(&mut self) -> Pin<Box<dyn AsyncWrite + Send + '_>>;
async fn finish(&mut self) -> Result<Vec<u8>>;
fn as_any(&self) -> &dyn Any; // for testing only
}
pub struct Spamc {
spamc_args: &'static [String],
spamc_args: Vec<String>,
spamc: Option<Child>,
stdout_reader: Option<JoinHandle<io::Result<Vec<u8>>>>,
}
@ -32,23 +35,25 @@ impl Spamc {
None => "/usr/bin/spamc",
};
pub fn new(spamc_args: &'static [String]) -> Self {
pub fn new(spamc_args: &[String]) -> Self {
Self {
spamc_args,
spamc_args: spamc_args.into(),
spamc: None,
stdout_reader: None,
}
}
}
#[async_trait]
impl Process for Spamc {
fn connect(&mut self) -> Result<()> {
// `Command::spawn` always succeeds when `spamc` can be invoked, even if
// logically the command is invalid, eg if it uses non-existing options.
let mut spamc = Command::new(Self::SPAMC_PROGRAM)
.args(self.spamc_args)
.args(&self.spamc_args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.kill_on_drop(true)
.spawn()?;
let mut stdout = spamc.stdout.take().unwrap();
@ -57,33 +62,33 @@ impl Process for Spamc {
// When processing large messages, `spamc` may begin to write its
// response to stdout while it is still receiving parts of the message
// body. Avoid blocking by reading stdout in a separate thread.
self.stdout_reader = Some(thread::spawn(move || {
// body. Avoid blocking by reading stdout in a separate task.
self.stdout_reader = Some(tokio::spawn(async move {
let mut output = Vec::new();
stdout.read_to_end(&mut output)?;
stdout.read_to_end(&mut output).await?;
Ok(output)
}));
Ok(())
}
fn writer(&mut self) -> &mut dyn Write {
fn writer(&mut self) -> Pin<Box<dyn AsyncWrite + Send + '_>> {
let spamc = self.spamc.as_mut().expect("spamc process not started");
spamc.stdin.as_mut().unwrap()
Box::pin(spamc.stdin.as_mut().unwrap())
}
fn finish(&mut self) -> Result<Vec<u8>> {
async fn finish(&mut self) -> Result<Vec<u8>> {
let mut spamc = self.spamc.take().expect("spamc process not started");
let status = spamc.wait()?;
let status = spamc.wait().await?;
let stdout = self
.stdout_reader
.take()
.expect("spamc stdout reader thread not available")
.join()
.expect("panic in spamc stdout reader thread")?;
.expect("spamc stdout reader task not available")
.await
.expect("panic in spamc stdout reader task")?;
if status.success() {
Ok(stdout)
@ -103,20 +108,18 @@ impl Process for Spamc {
}
}
impl Drop for Spamc {
fn drop(&mut self) {
// Kill (and wait on) `spamc`: a child process will continue to run even
// after its `Child` handle has gone out of scope.
if let Some(spamc) = self.spamc.as_mut() {
// The results are no longer of interest at this point.
let _ = spamc.kill();
let _ = spamc.wait();
}
}
pub struct ReceivedInfo<'macros> {
pub client_name_addr: Option<Cow<'macros, str>>,
pub my_hostname: Option<Cow<'macros, str>>,
pub mta: Option<Cow<'macros, str>>,
pub tls: Option<Cow<'macros, str>>,
pub auth: Option<Cow<'macros, str>>,
pub queue_id: &'macros str,
pub date_time: String,
}
pub struct Client {
process: Box<dyn Process>,
process: Box<dyn Process + Send>,
client_ip: IpAddr,
helo_host: Option<String>,
sender: String,
@ -130,7 +133,7 @@ pub struct Client {
impl Client {
pub fn new(
process: impl Process + 'static,
process: impl Process + Send + 'static,
client_ip: IpAddr,
helo_host: Option<&str>,
sender: String,
@ -173,63 +176,57 @@ impl Client {
self.process.connect()
}
// Implementation note: The send operations all may fail with an I/O error,
// and so return a `Result<()>` that is then unceremoniously unwrapped with
// `?` in the callback functions. This is acceptable, because we dont
// expect this to happen in normal circumstances, only when something is
// wrong with `spamc` configuration or operation.
pub fn send_envelope_sender(&mut self) -> Result<()> {
pub async fn send_envelope_sender(&mut self) -> Result<()> {
let buf = format!("X-Envelope-From: {}\r\n", self.sender);
self.process.writer().write_all(buf.as_bytes())?;
self.process.writer().write_all(buf.as_bytes()).await?;
self.bytes += buf.len();
Ok(())
}
pub fn send_envelope_recipients(&mut self) -> Result<()> {
pub async fn send_envelope_recipients(&mut self) -> Result<()> {
let buf = format!("X-Envelope-To: {}\r\n", self.recipients.join(",\r\n\t"));
self.process.writer().write_all(buf.as_bytes())?;
self.process.writer().write_all(buf.as_bytes()).await?;
self.bytes += buf.len();
Ok(())
}
pub fn send_synthesized_received_header(
&mut self,
client_name_addr: Option<&str>,
my_hostname: &str,
mta: &str,
tls: bool,
auth: bool,
queue_id: &str,
date_time: &str,
) -> Result<()> {
pub async fn send_synthesized_received_header(&mut self, info: ReceivedInfo<'_>) -> Result<()> {
// Sending this Received header is crucial: Milters dont see the
// MTAs own Received header. However, SpamAssassin draws a lot of
// information from that header. So we make one up and send it along.
let client_ip = self.client_ip.to_string();
let helo_host = self.helo_host.as_deref().unwrap_or(&client_ip);
let client_name_addr = info.client_name_addr.as_deref().unwrap_or(&client_ip);
let my_hostname = info.my_hostname.as_deref().unwrap_or("localhost");
let mta = info
.mta
.as_ref()
.and_then(|v| v.split_ascii_whitespace().next())
.unwrap_or("Postfix");
let buf = format!(
"Received: from {helo} ({client})\r\n\
\tby {hostname} ({mta}) with ESMTP{tls}{auth} id {id};\r\n\
\t{date_time}\r\n\
\t(envelope-from {sender})\r\n",
helo = self.helo_host.as_ref().unwrap_or(&client_ip),
client = client_name_addr.unwrap_or(&client_ip),
helo = helo_host,
client = client_name_addr,
hostname = my_hostname,
mta = mta,
tls = if tls { "S" } else { "" },
auth = if auth { "A" } else { "" },
id = queue_id,
date_time = date_time,
tls = if info.tls.is_some() { "S" } else { "" },
auth = if info.auth.is_some() { "A" } else { "" },
id = info.queue_id,
date_time = info.date_time,
sender = self.sender
);
self.process.writer().write_all(buf.as_bytes())?;
self.process.writer().write_all(buf.as_bytes()).await?;
self.bytes += buf.len();
self.synth_relay_inserted = true;
@ -237,7 +234,7 @@ impl Client {
Ok(())
}
pub fn send_header(&mut self, name: &str, value: &str) -> Result<()> {
pub async fn send_header(&mut self, name: &str, value: &str) -> Result<()> {
// As requested during milter protocol negotiation, the value includes
// leading whitespace. This lets us pass on whitespace exactly as is.
let value = email::ensure_crlf(value);
@ -250,7 +247,7 @@ impl Client {
self.headers.insert_if_absent(name, value);
}
self.process.writer().write_all(buf.as_bytes())?;
self.process.writer().write_all(buf.as_bytes()).await?;
self.bytes += buf.len();
self.header_count += 1;
@ -258,29 +255,30 @@ impl Client {
Ok(())
}
pub fn send_eoh(&mut self) -> Result<()> {
pub async fn send_eoh(&mut self) -> Result<()> {
let eoh = b"\r\n";
self.process.writer().write_all(eoh)?;
self.process.writer().write_all(eoh).await?;
self.bytes += eoh.len();
Ok(())
}
pub fn send_body_chunk(&mut self, bytes: &[u8]) -> Result<()> {
self.process.writer().write_all(bytes)?;
pub async fn send_body_chunk(&mut self, bytes: &[u8]) -> Result<()> {
self.process.writer().write_all(bytes).await?;
self.bytes += bytes.len();
Ok(())
}
pub fn process(
pub async fn process(
mut self,
id: &str,
actions: &(impl ActionContext + SetErrorReply),
reply: &mut impl SetErrorReply,
actions: &impl ContextActions,
config: &Config,
) -> milter::Result<Status> {
let output = match self.process.finish() {
) -> Result<Status> {
let output = match self.process.finish().await {
Ok(output) => output,
Err(e) => {
eprintln!("{}: failed to complete spamc communication: {}", id, e);
@ -304,18 +302,18 @@ impl Client {
let spam = rewriter.is_flagged_spam();
if spam && config.reject_spam() {
return reject_spam(id, actions, config);
return reject_spam(id, reply, config);
}
rewriter.rewrite_spam_assassin_headers(id, actions)?;
rewriter.rewrite_spam_assassin_headers(id, actions).await?;
if spam {
if !config.preserve_headers() {
rewriter.rewrite_rewrite_headers(id, actions)?;
rewriter.rewrite_rewrite_headers(id, actions).await?;
}
if !config.preserve_body() {
rewriter.rewrite_report_headers(id, actions)?;
replace_body(id, email.body, self.skipped, actions, config)?;
rewriter.rewrite_report_headers(id, actions).await?;
replace_body(id, email.body, self.skipped, actions, config).await?;
}
}
@ -323,15 +321,15 @@ impl Client {
}
}
fn reject_spam(id: &str, actions: &impl SetErrorReply, config: &Config) -> milter::Result<Status> {
fn reject_spam(id: &str, reply: &mut impl SetErrorReply, config: &Config) -> Result<Status> {
if config.dry_run() {
verbose!(config, "{}: rejected message flagged as spam [dry run, not done]", id);
Ok(Status::Accept)
} else {
actions.set_error_reply(
reply.set_error_reply(
config.reply_code(),
Some(config.reply_status_code()),
config.reply_text().lines().collect(),
config.reply_text().lines(),
)?;
verbose!(config, "{}: rejected message flagged as spam", id);
@ -343,13 +341,13 @@ fn reject_spam(id: &str, actions: &impl SetErrorReply, config: &Config) -> milte
}
}
fn replace_body(
async fn replace_body(
id: &str,
body: &[u8],
skipped: bool,
actions: &impl ActionContext,
actions: &impl ContextActions,
config: &Config,
) -> milter::Result<()> {
) -> Result<()> {
// Do not replace the message body when part of it was skipped. This only
// occurs when the milters max message size is less than that of `spamc`.
// This condition ensures message integrity in such a misconfigured setup.
@ -360,18 +358,20 @@ fn replace_body(
id,
config.max_message_size()
);
Ok(())
} else {
email::replace_body(id, body, actions, config)
email::replace_body(id, body, actions, config).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::{cell::RefCell, net::Ipv4Addr};
use byte_strings::c_str;
use indymilter::{ActionError, IntoCString, SmtpReplyError};
use std::{ffi::CString, net::Ipv4Addr, result, sync::Mutex};
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[derive(Debug, Default)]
struct MockSpamc {
buf: Vec<u8>,
output: Option<Vec<u8>>,
@ -383,20 +383,24 @@ mod tests {
}
fn with_output(output: Vec<u8>) -> Self {
Self { output: Some(output), ..Default::default() }
Self {
output: Some(output),
..Default::default()
}
}
}
#[async_trait]
impl Process for MockSpamc {
fn connect(&mut self) -> Result<()> {
Ok(())
}
fn writer(&mut self) -> &mut dyn Write {
&mut self.buf
fn writer(&mut self) -> Pin<Box<dyn AsyncWrite + Send + '_>> {
Box::pin(&mut self.buf)
}
fn finish(&mut self) -> Result<Vec<u8>> {
async fn finish(&mut self) -> Result<Vec<u8>> {
Ok(if let Some(output) = &self.output {
output.clone()
} else {
@ -413,101 +417,158 @@ mod tests {
process.as_any().downcast_ref().unwrap()
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[derive(Debug, Eq, PartialEq)]
enum Action {
AddHeader(String, String),
InsertHeader(usize, String, String),
ReplaceHeader(String, usize, Option<String>),
AppendBodyChunk(Vec<u8>),
SetErrorReply(String, Option<String>, Vec<String>),
AddHeader(CString, CString),
InsertHeader(i32, CString, CString),
ChangeHeader(CString, i32, Option<CString>),
ReplaceBody(Vec<u8>),
}
#[derive(Clone, Debug, Default)]
struct MockActionContext {
called: RefCell<Vec<Action>>,
#[derive(Debug, Default)]
struct MockEomActions {
called: Mutex<Vec<Action>>,
}
impl MockActionContext {
impl MockEomActions {
fn new() -> Self {
Default::default()
}
}
impl ActionContext for MockActionContext {
fn add_header(&self, name: &str, value: &str) -> milter::Result<()> {
let action = Action::AddHeader(name.to_owned(), value.to_owned());
self.called.borrow_mut().push(action);
#[async_trait]
impl ContextActions for MockEomActions {
async fn add_header<'cx, 'k, 'v>(
&'cx self,
name: impl IntoCString + Send + 'k,
value: impl IntoCString + Send + 'v,
) -> result::Result<(), ActionError> {
let action = Action::AddHeader(name.into_c_string(), value.into_c_string());
self.called.lock().unwrap().push(action);
Ok(())
}
fn insert_header(&self, index: usize, name: &str, value: &str) -> milter::Result<()> {
let action = Action::InsertHeader(index, name.to_owned(), value.to_owned());
self.called.borrow_mut().push(action);
async fn insert_header<'cx, 'k, 'v>(
&'cx self,
index: i32,
name: impl IntoCString + Send + 'k,
value: impl IntoCString + Send + 'v,
) -> result::Result<(), ActionError> {
let action = Action::InsertHeader(index, name.into_c_string(), value.into_c_string());
self.called.lock().unwrap().push(action);
Ok(())
}
fn replace_header(&self, name: &str, index: usize, value: Option<&str>) -> milter::Result<()> {
let action = Action::ReplaceHeader(name.to_owned(), index, value.map(|v| v.to_owned()));
self.called.borrow_mut().push(action);
async fn change_header<'cx, 'k, 'v>(
&'cx self,
name: impl IntoCString + Send + 'k,
index: i32,
value: Option<impl IntoCString + Send + 'v>,
) -> result::Result<(), ActionError> {
let action = Action::ChangeHeader(
name.into_c_string(),
index,
value.map(|v| v.into_c_string()),
);
self.called.lock().unwrap().push(action);
Ok(())
}
fn append_body_chunk(&self, bytes: &[u8]) -> milter::Result<()> {
let action = Action::AppendBodyChunk(bytes.to_owned());
self.called.borrow_mut().push(action);
async fn replace_body<'cx, 'a>(
&'cx self,
chunk: &'a [u8],
) -> result::Result<(), ActionError> {
let action = Action::ReplaceBody(chunk.to_vec());
self.called.lock().unwrap().push(action);
Ok(())
}
fn replace_sender(&self, _: &str, _: Option<&str>) -> milter::Result<()> {
unimplemented!();
async fn change_sender<'cx, 'a, 'b>(
&'cx self,
_: impl IntoCString + Send + 'a,
_: Option<impl IntoCString + Send + 'b>,
) -> result::Result<(), ActionError> {
unimplemented!()
}
fn add_recipient(&self, _: &str, _: Option<&str>) -> milter::Result<()> {
unimplemented!();
async fn add_recipient<'cx, 'a>(
&'cx self,
_: impl IntoCString + Send + 'a,
) -> result::Result<(), ActionError> {
unimplemented!()
}
fn remove_recipient(&self, _: &str) -> milter::Result<()> {
unimplemented!();
async fn add_recipient_ext<'cx, 'a, 'b>(
&'cx self,
_: impl IntoCString + Send + 'a,
_: Option<impl IntoCString + Send + 'b>,
) -> result::Result<(), ActionError> {
unimplemented!()
}
fn quarantine(&self, _: &str) -> milter::Result<()> {
unimplemented!();
async fn delete_recipient<'cx, 'a>(
&'cx self,
_: impl IntoCString + Send + 'a,
) -> result::Result<(), ActionError> {
unimplemented!()
}
fn signal_progress(&self) -> milter::Result<()> {
unimplemented!();
async fn progress<'cx>(&'cx self) -> result::Result<(), ActionError> {
unimplemented!()
}
async fn quarantine<'cx, 'a>(
&'cx self,
_: impl IntoCString + Send + 'a,
) -> result::Result<(), ActionError> {
unimplemented!()
}
}
impl SetErrorReply for MockActionContext {
fn set_error_reply(
&self,
code: &str,
ext_code: Option<&str>,
msg_lines: Vec<&str>,
) -> milter::Result<()> {
let action = Action::SetErrorReply(
code.to_owned(),
ext_code.map(|c| c.to_owned()),
msg_lines.into_iter().map(|l| l.to_owned()).collect(),
);
self.called.borrow_mut().push(action);
#[derive(Debug, Default)]
struct MockSmtpReply {
error_reply: Option<(String, Option<String>, Vec<CString>)>,
}
impl MockSmtpReply {
fn new() -> Self {
Default::default()
}
}
impl SetErrorReply for MockSmtpReply {
fn set_error_reply<I, T>(
&mut self,
rcode: &str,
xcode: Option<&str>,
message: I,
) -> result::Result<(), SmtpReplyError>
where
I: IntoIterator<Item = T>,
T: IntoCString,
{
self.error_reply = Some((
rcode.into(),
xcode.map(|c| c.into()),
message.into_iter().map(|l| l.into_c_string()).collect(),
));
Ok(())
}
}
const IP: IpAddr = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
const HELO: &str = "helo";
const ID: &str = "NONE";
#[test]
fn client_send_writes_bytes() {
#[tokio::test]
async fn client_send_writes_bytes() {
let spamc = MockSpamc::new();
let mut client = Client::new(spamc, IP, Some(HELO), String::from("sender"));
client.send_header("name1", " value1").unwrap();
client.send_header("name2", " value2\n\tcontinued").unwrap();
client.send_eoh().unwrap();
client.send_body_chunk(b"body").unwrap();
let mut client = Client::new(spamc, IP, Some(HELO), "sender".into());
client.send_header("name1", " value1").await.unwrap();
client.send_header("name2", " value2\n\tcontinued").await.unwrap();
client.send_eoh().await.unwrap();
client.send_body_chunk(b"body").await.unwrap();
assert_eq!(client.bytes_written(), 48);
assert_eq!(
@ -516,20 +577,20 @@ mod tests {
);
}
#[test]
fn client_send_envelope_addresses() {
#[tokio::test]
async fn client_send_envelope_addresses() {
let spamc = MockSpamc::new();
let sender = "<sender@gluet.ch>";
let recipient1 = "<recipient1@gluet.ch>";
let recipient2 = "<recipient2@gluet.ch>";
let mut client = Client::new(spamc, IP, Some(HELO), String::from(sender));
client.add_recipient(String::from(recipient1));
client.add_recipient(String::from(recipient2));
let mut client = Client::new(spamc, IP, Some(HELO), sender.into());
client.add_recipient(recipient1.into());
client.add_recipient(recipient2.into());
client.send_envelope_sender().unwrap();
client.send_envelope_recipients().unwrap();
client.send_envelope_sender().await.unwrap();
client.send_envelope_recipients().await.unwrap();
assert_eq!(
as_mock_spamc(client.process.as_ref()).buf,
@ -538,90 +599,104 @@ mod tests {
X-Envelope-To: {},\r\n\
\t{}\r\n",
sender, recipient1, recipient2
).as_bytes()
)
.as_bytes()
);
}
#[test]
fn client_process_invalid_response() {
#[tokio::test]
async fn client_process_invalid_response() {
let spamc = MockSpamc::with_output(b"invalid message response".to_vec());
let actions = MockActionContext::new();
let client = Client::new(spamc, IP, Some(HELO), "sender".into());
let mut reply = MockSmtpReply::new();
let actions = MockEomActions::new();
let config = Default::default();
let client = Client::new(spamc, IP, Some(HELO), String::from("sender"));
let status = client.process("id", &actions, &config).unwrap();
let status = client.process(ID, &mut reply, &actions, &config).await.unwrap();
assert_eq!(status, Status::Tempfail);
}
#[test]
fn client_process_reject_spam() {
#[tokio::test]
async fn client_process_reject_spam() {
let spamc = MockSpamc::with_output(b"X-Spam-Flag: YES\r\n\r\n".to_vec());
let actions = MockActionContext::new();
let mut builder = Config::builder();
builder.reject_spam(true);
let config = builder.build();
let client = Client::new(spamc, IP, Some(HELO), String::from("sender"));
let status = client.process("id", &actions, &config).unwrap();
let client = Client::new(spamc, IP, Some(HELO), "sender".into());
let mut reply = MockSmtpReply::new();
let actions = MockEomActions::new();
let config = Config::builder().reject_spam(true).build();
let status = client.process(ID, &mut reply, &actions, &config).await.unwrap();
assert_eq!(status, Status::Reject);
assert_eq!(
actions.called.borrow().as_slice(),
[
Action::SetErrorReply(
"550".into(),
Some("5.7.1".into()),
vec!["Spam message refused".into()],
),
]
reply.error_reply,
Some((
"550".into(),
Some("5.7.1".into()),
vec![c_str!("Spam message refused").into()],
)),
);
}
#[test]
fn client_process_rewrite_spam() {
#[tokio::test]
async fn client_process_rewrite_spam() {
let spamc = MockSpamc::with_output(
b"X-Spam-Flag: YES\r\nX-Spam-Level: *****\r\n\r\nReport".to_vec(),
);
let actions = MockActionContext::new();
let mut client = Client::new(spamc, IP, Some(HELO), "sender".into());
client.send_header("x-spam-level", " *").await.unwrap();
client.send_header("x-spam-report", " ...").await.unwrap();
let mut reply = MockSmtpReply::new();
let actions = MockEomActions::new();
let config = Default::default();
let mut client = Client::new(spamc, IP, Some(HELO), String::from("sender"));
client.send_header("x-spam-level", " *").unwrap();
client.send_header("x-spam-report", " ...").unwrap();
let status = client.process("id", &actions, &config).unwrap();
let status = client.process(ID, &mut reply, &actions, &config).await.unwrap();
assert_eq!(status, Status::Continue);
let called = actions.called.lock().unwrap();
assert_eq!(
actions.called.borrow().as_slice(),
called.as_slice(),
[
Action::ReplaceHeader("X-Spam-Level".into(), 1, None),
Action::InsertHeader(0, "X-Spam-Level".into(), " *****".into()),
Action::InsertHeader(0, "X-Spam-Flag".into(), " YES".into()),
Action::ReplaceHeader("x-spam-report".into(), 1, None),
Action::AppendBodyChunk(b"Report".to_vec()),
Action::ChangeHeader(c_str!("X-Spam-Level").into(), 1, None),
Action::InsertHeader(0, c_str!("X-Spam-Level").into(), c_str!(" *****").into()),
Action::InsertHeader(0, c_str!("X-Spam-Flag").into(), c_str!(" YES").into()),
Action::ChangeHeader(c_str!("x-spam-report").into(), 1, None),
Action::ReplaceBody(b"Report".to_vec()),
]
);
}
#[test]
fn client_process_skipped_body_not_replaced() {
#[tokio::test]
async fn client_process_skipped_body_not_replaced() {
let spamc = MockSpamc::with_output(
b"X-Spam-Flag: YES\r\nX-Spam-Level: *****\r\n\r\nReport".to_vec(),
);
let actions = MockActionContext::new();
let config = Default::default();
let mut client = Client::new(spamc, IP, Some(HELO), String::from("sender"));
let mut client = Client::new(spamc, IP, Some(HELO), "sender".into());
client.skip_body();
let status = client.process("id", &actions, &config).unwrap();
let mut reply = MockSmtpReply::new();
let actions = MockEomActions::new();
let config = Default::default();
let status = client.process(ID, &mut reply, &actions, &config).await.unwrap();
assert_eq!(status, Status::Continue);
let called = actions.called.borrow();
assert!(called.contains(&Action::InsertHeader(0, "X-Spam-Level".into(), " *****".into())));
assert!(!called.contains(&Action::AppendBodyChunk(b"Report".to_vec())));
let called = actions.called.lock().unwrap();
assert!(called.contains(&Action::InsertHeader(
0,
c_str!("X-Spam-Level").into(),
c_str!(" *****").into()
)));
assert!(!called.contains(&Action::ReplaceBody(b"Report".to_vec())));
}
}

View file

@ -17,7 +17,9 @@ where
K: AsRef<str>,
{
pub fn new() -> Self {
Self { entries: Default::default() }
Self {
entries: Default::default(),
}
}
pub fn iter(&self) -> impl Iterator<Item = (&K, &V)> {
@ -29,15 +31,22 @@ where
}
pub fn contains_key<Q: AsRef<str>>(&self, key: Q) -> bool {
self.iter().any(|e| e.0.as_ref().eq_ignore_ascii_case(key.as_ref()))
self.iter()
.any(|e| e.0.as_ref().eq_ignore_ascii_case(key.as_ref()))
}
pub fn get<Q: AsRef<str>>(&self, key: Q) -> Option<&V> {
self.iter().find(|e| e.0.as_ref().eq_ignore_ascii_case(key.as_ref())).map(|e| e.1)
self.iter()
.find(|e| e.0.as_ref().eq_ignore_ascii_case(key.as_ref()))
.map(|e| e.1)
}
pub fn insert(&mut self, key: K, value: V) -> Option<V> {
match self.entries.iter_mut().find(|e| e.0.as_ref().eq_ignore_ascii_case(key.as_ref())) {
match self
.entries
.iter_mut()
.find(|e| e.0.as_ref().eq_ignore_ascii_case(key.as_ref()))
{
None => {
self.entries.push((key, value));
None
@ -65,23 +74,25 @@ impl<V> StrVecMap<String, V> {
/// A vector set containing ASCII-case-insensitive `AsRef<str>` elements.
#[derive(Clone, Debug, Default)]
pub struct StrVecSet<E> {
map: StrVecMap<E, ()>,
pub struct StrVecSet<T> {
map: StrVecMap<T, ()>,
}
impl<E> StrVecSet<E>
impl<T> StrVecSet<T>
where
E: AsRef<str>,
T: AsRef<str>,
{
pub fn new() -> Self {
Self { map: StrVecMap::new() }
Self {
map: StrVecMap::new(),
}
}
pub fn contains<Q: AsRef<str>>(&self, key: Q) -> bool {
self.map.contains_key(key)
}
pub fn insert(&mut self, key: E) -> bool {
pub fn insert(&mut self, key: T) -> bool {
self.map.insert(key, ()).is_none()
}
}

View file

@ -1,12 +1,10 @@
use ipnet::IpNet;
use once_cell::sync::OnceCell;
use regex::{Error, Regex};
use std::{collections::HashSet, net::IpAddr, str::FromStr};
/// A builder for SpamAssassin Milter configuration objects.
#[derive(Clone, Debug)]
pub struct ConfigBuilder {
milter_debug_level: i32,
use_trusted_networks: bool,
trusted_networks: HashSet<IpNet>,
auth_untrusted: bool,
@ -24,28 +22,23 @@ pub struct ConfigBuilder {
}
impl ConfigBuilder {
pub fn milter_debug_level(&mut self, value: i32) -> &mut Self {
self.milter_debug_level = value;
self
}
pub fn use_trusted_networks(&mut self, value: bool) -> &mut Self {
pub fn use_trusted_networks(mut self, value: bool) -> Self {
self.use_trusted_networks = value;
self
}
pub fn trusted_network(&mut self, net: IpNet) -> &mut Self {
pub fn trusted_network(mut self, net: IpNet) -> Self {
self.use_trusted_networks = true;
self.trusted_networks.insert(net);
self
}
pub fn auth_untrusted(&mut self, value: bool) -> &mut Self {
pub fn auth_untrusted(mut self, value: bool) -> Self {
self.auth_untrusted = value;
self
}
pub fn spamc_args<I, S>(&mut self, args: I) -> &mut Self
pub fn spamc_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
@ -54,58 +47,58 @@ impl ConfigBuilder {
self
}
pub fn synth_relay(&mut self, value: SynthRelayPosition) -> &mut Self {
pub fn synth_relay(mut self, value: SynthRelayPosition) -> Self {
self.synth_relay = Some(value);
self
}
pub fn max_message_size(&mut self, value: usize) -> &mut Self {
pub fn max_message_size(mut self, value: usize) -> Self {
self.max_message_size = value;
self
}
pub fn dry_run(&mut self, value: bool) -> &mut Self {
pub fn dry_run(mut self, value: bool) -> Self {
self.dry_run = value;
self
}
pub fn reject_spam(&mut self, value: bool) -> &mut Self {
pub fn reject_spam(mut self, value: bool) -> Self {
self.reject_spam = value;
self
}
pub fn reply_code(&mut self, value: String) -> &mut Self {
pub fn reply_code(mut self, value: String) -> Self {
self.reply_code = value;
self
}
pub fn reply_status_code(&mut self, value: String) -> &mut Self {
pub fn reply_status_code(mut self, value: String) -> Self {
self.reply_status_code = value;
self
}
pub fn reply_text(&mut self, value: String) -> &mut Self {
pub fn reply_text(mut self, value: String) -> Self {
self.reply_text = value;
self
}
pub fn preserve_headers(&mut self, value: bool) -> &mut Self {
pub fn preserve_headers(mut self, value: bool) -> Self {
self.preserve_headers = value;
self
}
pub fn preserve_body(&mut self, value: bool) -> &mut Self {
pub fn preserve_body(mut self, value: bool) -> Self {
self.preserve_body = value;
self
}
pub fn verbose(&mut self, value: bool) -> &mut Self {
pub fn verbose(mut self, value: bool) -> Self {
self.verbose = value;
self
}
pub fn build(self) -> Config {
// TODO These invariants are enforced in `main`. In a future revision,
// These invariants are enforced in `main`. In a future revision,
// consider replacing the assertions with a `Result` return type.
assert!(
self.use_trusted_networks || self.trusted_networks.is_empty(),
@ -119,7 +112,6 @@ impl ConfigBuilder {
);
Config {
milter_debug_level: self.milter_debug_level,
use_trusted_networks: self.use_trusted_networks,
trusted_networks: self.trusted_networks,
auth_untrusted: self.auth_untrusted,
@ -145,7 +137,6 @@ fn is_valid_reply_code(s: &str) -> bool {
impl Default for ConfigBuilder {
fn default() -> Self {
Self {
milter_debug_level: Default::default(),
use_trusted_networks: Default::default(),
trusted_networks: Default::default(),
auth_untrusted: Default::default(),
@ -156,10 +147,10 @@ impl Default for ConfigBuilder {
reject_spam: Default::default(),
// This reply code and enhanced status code are the most appropriate
// choices according to RFCs 5321 and 3463.
reply_code: String::from("550"),
reply_status_code: String::from("5.7.1"),
reply_code: "550".into(),
reply_status_code: "5.7.1".into(),
// Generic reply text that makes no mention of SpamAssassin.
reply_text: String::from("Spam message refused"),
reply_text: "Spam message refused".into(),
preserve_headers: Default::default(),
preserve_body: Default::default(),
verbose: Default::default(),
@ -170,7 +161,6 @@ impl Default for ConfigBuilder {
/// A configuration object for SpamAssassin Milter.
#[derive(Clone, Debug)]
pub struct Config {
milter_debug_level: i32,
use_trusted_networks: bool,
trusted_networks: HashSet<IpNet>,
auth_untrusted: bool,
@ -192,10 +182,6 @@ impl Config {
Default::default()
}
pub fn milter_debug_level(&self) -> i32 {
self.milter_debug_level
}
pub fn use_trusted_networks(&self) -> bool {
self.use_trusted_networks
}
@ -278,25 +264,15 @@ impl FromStr for SynthRelayPosition {
}
}
static CONFIG: OnceCell<Config> = OnceCell::new();
pub fn init(config: Config) {
CONFIG.set(config).expect("configuration already initialized");
}
pub fn get() -> &'static Config {
CONFIG.get().expect("configuration not initialized")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trusted_networks_config() {
let mut builder = Config::builder();
builder.trusted_network("127.0.0.1/8".parse().unwrap());
let config = builder.build();
let config = Config::builder()
.trusted_network("127.0.0.1/8".parse().unwrap())
.build();
assert!(config.use_trusted_networks());
assert!(config.is_in_trusted_networks(&"127.0.0.1".parse().unwrap()));
@ -305,10 +281,10 @@ mod tests {
#[test]
fn spamc_args_extends_args() {
let mut builder = Config::builder();
builder.spamc_args(&["-p", "3030"]);
builder.spamc_args(&["-x"]);
let config = builder.build();
let config = Config::builder()
.spamc_args(&["-p", "3030"])
.spamc_args(&["-x"])
.build();
assert_eq!(
config.spamc_args(),

View file

@ -3,20 +3,21 @@ use crate::{
config::Config,
error::{Error, Result},
};
use milter::ActionContext;
use indymilter::ContextActions;
use once_cell::sync::Lazy;
use std::{
ffi::CString,
fmt::{self, Display, Formatter},
str,
};
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct Header<'a> {
pub name: &'a str,
pub value: &'a str,
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct Email<'a> {
pub header: Vec<Header<'a>>,
pub body: &'a [u8],
@ -107,7 +108,7 @@ pub fn is_spam_assassin_header(name: &str) -> bool {
// Values use CRLF line breaks and include leading whitespace.
pub type HeaderMap = StrVecMap<String, String>;
pub type HeaderSet<'e> = StrVecSet<&'e str>;
pub type HeaderSet<'a> = StrVecSet<&'a str>;
pub static REWRITE_HEADERS: Lazy<HeaderSet<'static>> = Lazy::new(|| {
let mut h = HeaderSet::new();
@ -230,52 +231,54 @@ impl<'a, 'c> HeaderRewriter<'a, 'c> {
self.spam_flag.unwrap_or(false)
}
pub fn rewrite_spam_assassin_headers(
pub async fn rewrite_spam_assassin_headers(
&self,
id: &str,
actions: &impl ActionContext,
) -> milter::Result<()> {
actions: &impl ContextActions,
) -> Result<()> {
let mods = self.spam_assassin_mods.iter();
if self.prepend.unwrap_or(false) {
// Prepend X-Spam- headers in reverse order, so that they appear
// in the order received from SpamAssassin.
execute_mods(id, mods.rev(), actions, self.config)?;
execute_mods(id, mods.rev(), actions, self.config).await?;
} else {
execute_mods(id, mods, actions, self.config)?;
execute_mods(id, mods, actions, self.config).await?;
}
// Delete all incoming X-Spam- headers not returned by SpamAssassin to
// get rid of foreign X-Spam-Flag etc. headers.
let deletions = self.original.keys()
let deletions = self
.original
.keys()
.filter(|n| is_spam_assassin_header(n) && !self.processed.contains(n))
.map(|name| HeaderMod::Delete { name })
.collect::<Vec<_>>();
execute_mods(id, deletions.iter(), actions, self.config)
execute_mods(id, deletions.iter(), actions, self.config).await
}
pub fn rewrite_rewrite_headers(
pub async fn rewrite_rewrite_headers(
&self,
id: &str,
actions: &impl ActionContext,
) -> milter::Result<()> {
execute_mods(id, self.rewrite_mods.iter(), actions, self.config)
actions: &impl ContextActions,
) -> Result<()> {
execute_mods(id, self.rewrite_mods.iter(), actions, self.config).await
}
pub fn rewrite_report_headers(
pub async fn rewrite_report_headers(
&self,
id: &str,
actions: &impl ActionContext,
) -> milter::Result<()> {
execute_mods(id, self.report_mods.iter(), actions, self.config)
actions: &impl ContextActions,
) -> Result<()> {
execute_mods(id, self.report_mods.iter(), actions, self.config).await
}
}
fn execute_mods<'a, I>(
async fn execute_mods<'a, I>(
id: &str,
mods: I,
actions: &impl ActionContext,
actions: &impl ContextActions,
config: &Config,
) -> milter::Result<()>
) -> Result<()>
where
I: IntoIterator<Item = &'a HeaderMod<'a>>,
{
@ -284,30 +287,30 @@ where
verbose!(config, "{}: rewriting header: {} [dry run, not done]", id, m);
} else {
verbose!(config, "{}: rewriting header: {}", id, m);
m.execute(actions)?;
m.execute(actions).await?;
}
}
Ok(())
}
pub fn replace_body(
pub async fn replace_body(
id: &str,
body: &[u8],
actions: &impl ActionContext,
actions: &impl ContextActions,
config: &Config,
) -> milter::Result<()> {
) -> Result<()> {
if config.dry_run() {
verbose!(config, "{}: replacing message body [dry run, not done]", id);
} else {
verbose!(config, "{}: replacing message body", id);
actions.append_body_chunk(body)?;
actions.replace_body(body).await?;
}
Ok(())
}
/// A header rewriting modification operation. These are intended to operate
/// only on the first instance of headers occurring multiple times.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum HeaderMod<'a> {
Add { name: &'a str, value: &'a str, prepend: bool },
Replace { name: &'a str, value: &'a str, prepend: bool },
@ -316,39 +319,41 @@ enum HeaderMod<'a> {
}
impl HeaderMod<'_> {
fn execute(&self, actions: &impl ActionContext) -> milter::Result<()> {
use HeaderMod::*;
async fn execute(&self, actions: &impl ContextActions) -> Result<()> {
// The milter library is smart enough to treat the name in a
// case-insensitive manner, eg Subject may replace sUbject.
match *self {
Add { name, value, prepend } => add_header(actions, name, value, prepend),
Replace { name, value, prepend } => {
delete_header(actions, name)?;
add_header(actions, name, value, prepend)?;
Ok(())
Self::Add { name, value, prepend } => add_header(actions, name, value, prepend).await?,
Self::Replace { name, value, prepend } => {
delete_header(actions, name).await?;
add_header(actions, name, value, prepend).await?;
}
Modify { name, value } => actions.replace_header(name, 1, Some(&ensure_lf(value))),
Delete { name } => delete_header(actions, name),
Self::Modify { name, value } => {
actions.change_header(name, 1, Some(ensure_lf(value))).await?;
}
Self::Delete { name } => delete_header(actions, name).await?,
}
Ok(())
}
}
fn add_header(
actions: &impl ActionContext,
async fn add_header(
actions: &impl ContextActions,
name: &str,
value: &str,
prepend: bool,
) -> milter::Result<()> {
) -> Result<()> {
if prepend {
actions.insert_header(0, name, &ensure_lf(value))
actions.insert_header(0, name, ensure_lf(value)).await?;
} else {
actions.add_header(name, &ensure_lf(value))
actions.add_header(name, ensure_lf(value)).await?;
}
Ok(())
}
fn delete_header(actions: &impl ActionContext, name: &str) -> milter::Result<()> {
actions.replace_header(name, 1, None)
async fn delete_header(actions: &impl ContextActions, name: &str) -> Result<()> {
actions.change_header(name, 1, None::<CString>).await?;
Ok(())
}
impl Display for HeaderMod<'_> {
@ -454,7 +459,7 @@ mod tests {
#[test]
fn header_rewriter_flags_spam() {
let mut headers = HeaderMap::new();
headers.insert(String::from("x-spam-flag"), String::from(" no"));
headers.insert("x-spam-flag".into(), " no".into());
let config = Default::default();
let mut rewriter = HeaderRewriter::new(headers, &config);
@ -487,10 +492,10 @@ mod tests {
#[test]
fn header_rewriter_adds_and_replaces_headers() {
let mut headers = HeaderMap::new();
headers.insert(String::from("x-spam-level"), String::from(" ***"));
headers.insert(String::from("subject"), String::from(" original"));
headers.insert(String::from("x-spam-prev-subject"), String::from(" very original"));
headers.insert(String::from("to"), String::from(" recipient@gluet.ch"));
headers.insert("x-spam-level".into(), " ***".into());
headers.insert("subject".into(), " original".into());
headers.insert("x-spam-prev-subject".into(), " very original".into());
headers.insert("to".into(), " recipient@gluet.ch".into());
let config = Default::default();
let mut rewriter = HeaderRewriter::new(headers, &config);

View file

@ -1,3 +1,4 @@
use indymilter::{ActionError, SmtpReplyError};
use std::{
error,
fmt::{self, Display, Formatter},
@ -6,9 +7,11 @@ use std::{
pub type Result<T> = result::Result<T, Error>;
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum Error {
ParseEmail,
SmtpReply,
Action,
// For our purposes it is enough to record just the error message of I/O
// errors, no need to keep the `io::Error` itself around.
Io(String),
@ -16,25 +19,31 @@ pub enum Error {
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
use Error::*;
match self {
ParseEmail => write!(f, "failed to parse email"),
Io(msg) => msg.fmt(f),
Self::ParseEmail => write!(f, "failed to parse email"),
Self::SmtpReply => write!(f, "could not configure SMTP error reply"),
Self::Action => write!(f, "could not execute milter context action"),
Self::Io(msg) => msg.fmt(f),
}
}
}
impl error::Error for Error {}
impl From<io::Error> for Error {
fn from(error: io::Error) -> Self {
Error::Io(error.to_string()) // just record the error message
impl From<SmtpReplyError> for Error {
fn from(_: SmtpReplyError) -> Self {
Self::SmtpReply
}
}
impl From<Error> for milter::Error {
fn from(error: Error) -> Self {
milter::Error::Custom(error.into())
impl From<ActionError> for Error {
fn from(_: ActionError) -> Self {
Self::Action
}
}
impl From<io::Error> for Error {
fn from(error: io::Error) -> Self {
Self::Io(error.to_string()) // just record the error message
}
}

View file

@ -6,11 +6,6 @@ macro_rules! verbose {
::std::eprintln!($($arg)*);
}
};
($($arg:tt)*) => {
if $crate::config::get().verbose() {
::std::eprintln!($($arg)*);
}
};
}
mod callbacks;
@ -20,9 +15,9 @@ mod config;
mod email;
mod error;
use crate::callbacks::*;
pub use crate::config::{Config, ConfigBuilder, SynthRelayPosition};
use milter::Milter;
use indymilter::IntoListener;
use std::{future::Future, io};
/// The name of the SpamAssassin Milter application.
pub const MILTER_NAME: &str = "SpamAssassin Milter";
@ -33,46 +28,35 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Starts SpamAssassin Milter listening on the given socket using the supplied
/// configuration.
///
/// This is a blocking call.
///
/// # Errors
///
/// If execution of the milter fails, an error variant of type `milter::Error`
/// is returned.
/// If execution of the milter fails, an error is returned.
///
/// # Examples
///
/// ```no_run
/// use spamassassin_milter::Config;
/// ```
/// # async fn f() -> std::io::Result<()> {
/// use std::process;
/// use tokio::{net::TcpListener, signal};
///
/// let socket = "inet:3000@localhost";
/// let config = Config::builder().build();
/// let listener = TcpListener::bind("127.0.0.1:3000").await?;
/// let config = Default::default();
/// let shutdown = signal::ctrl_c();
///
/// if let Err(e) = spamassassin_milter::run(socket, config) {
/// if let Err(e) = spamassassin_milter::run(listener, config, shutdown).await {
/// eprintln!("failed to run spamassassin-milter: {}", e);
/// process::exit(1);
/// }
/// # Ok(())
/// # }
/// ```
pub fn run(socket: &str, config: Config) -> milter::Result<()> {
milter::set_debug_level(config.milter_debug_level());
pub async fn run(
listener: impl IntoListener,
config: Config,
shutdown: impl Future,
) -> io::Result<()> {
let callbacks = callbacks::make_callbacks(config);
let config = Default::default();
config::init(config);
Milter::new(socket)
.name(MILTER_NAME)
.on_negotiate(negotiate_callback)
.on_connect(connect_callback)
.on_helo(helo_callback)
.on_mail(mail_callback)
.on_rcpt(rcpt_callback)
.on_data(data_callback)
.on_header(header_callback)
.on_eoh(eoh_callback)
.on_body(body_callback)
.on_eom(eom_callback)
.on_abort(abort_callback)
.on_close(close_callback)
.remove_socket(true)
.run()
indymilter::run(listener, callbacks, config, shutdown).await
}

View file

@ -1,11 +1,20 @@
use clap::{App, Arg, ArgMatches, Error, ErrorKind, Result};
use spamassassin_milter::Config;
use std::{net::IpAddr, process};
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 tokio::{
fs,
net::{TcpListener, UnixListener},
sync::oneshot,
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_MILTER_DEBUG_LEVEL: &str = "MILTER_DEBUG_LEVEL";
const ARG_PRESERVE_BODY: &str = "PRESERVE_BODY";
const ARG_PRESERVE_HEADERS: &str = "PRESERVE_HEADERS";
const ARG_REJECT_SPAM: &str = "REJECT_SPAM";
@ -18,93 +27,129 @@ const ARG_VERBOSE: &str = "VERBOSE";
const ARG_SOCKET: &str = "SOCKET";
const ARG_SPAMC_ARGS: &str = "SPAMC_ARGS";
fn main() {
use spamassassin_milter::{MILTER_NAME, VERSION};
let matches = App::new(MILTER_NAME)
#[tokio::main]
async fn main() {
let command = Command::new(MILTER_NAME)
.version(VERSION)
.arg(Arg::with_name(ARG_AUTH_UNTRUSTED)
.short("a")
.arg(Arg::new(ARG_AUTH_UNTRUSTED)
.short('a')
.long("auth-untrusted")
.help("Treat authenticated senders as untrusted"))
.arg(Arg::with_name(ARG_DRY_RUN)
.short("n")
.arg(Arg::new(ARG_DRY_RUN)
.short('n')
.long("dry-run")
.help("Process messages without applying any changes"))
.arg(Arg::with_name(ARG_MAX_MESSAGE_SIZE)
.short("s")
.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::with_name(ARG_MILTER_DEBUG_LEVEL)
.long("milter-debug-level")
.value_name("LEVEL")
.possible_values(&["0", "1", "2", "3", "4", "5", "6"])
.help("Set the milter library debug level")
.hidden(true) // not documented for now
.hide_possible_values(true))
.arg(Arg::with_name(ARG_PRESERVE_BODY)
.short("B")
.arg(Arg::new(ARG_PRESERVE_BODY)
.short('B')
.long("preserve-body")
.help("Suppress rewriting of message body"))
.arg(Arg::with_name(ARG_PRESERVE_HEADERS)
.short("H")
.arg(Arg::new(ARG_PRESERVE_HEADERS)
.short('H')
.long("preserve-headers")
.help("Suppress rewriting of Subject/From/To headers"))
.arg(Arg::with_name(ARG_REJECT_SPAM)
.short("r")
.arg(Arg::new(ARG_REJECT_SPAM)
.short('r')
.long("reject-spam")
.help("Reject messages flagged as spam"))
.arg(Arg::with_name(ARG_REPLY_CODE)
.short("C")
.arg(Arg::new(ARG_REPLY_CODE)
.short('C')
.long("reply-code")
.value_name("CODE")
.help("Reply code when rejecting messages"))
.arg(Arg::with_name(ARG_REPLY_STATUS_CODE)
.short("S")
.arg(Arg::new(ARG_REPLY_STATUS_CODE)
.short('S')
.long("reply-status-code")
.value_name("CODE")
.help("Status code when rejecting messages"))
.arg(Arg::with_name(ARG_REPLY_TEXT)
.short("R")
.arg(Arg::new(ARG_REPLY_TEXT)
.short('R')
.long("reply-text")
.value_name("MSG")
.help("Reply text when rejecting messages"))
.arg(Arg::with_name(ARG_SYNTH_RELAY)
.arg(Arg::new(ARG_SYNTH_RELAY)
.long("synth-relay")
.value_name("POS")
.help("Synthesize relay header after position"))
.arg(Arg::with_name(ARG_TRUSTED_NETWORKS)
.short("t")
.arg(Arg::new(ARG_TRUSTED_NETWORKS)
.short('t')
.long("trusted-networks")
.value_name("NETS")
.use_delimiter(true)
.use_value_delimiter(true)
.help("Trust connections from these networks"))
.arg(Arg::with_name(ARG_VERBOSE)
.short("v")
.arg(Arg::new(ARG_VERBOSE)
.short('v')
.long("verbose")
.help("Enable verbose operation logging"))
.arg(Arg::with_name(ARG_SOCKET)
.arg(Arg::new(ARG_SOCKET)
.required(true)
.help("Listening socket of the milter"))
.arg(Arg::with_name(ARG_SPAMC_ARGS)
.arg(Arg::new(ARG_SPAMC_ARGS)
.last(true)
.multiple(true)
.help("Additional arguments to pass to spamc"))
.get_matches();
.multiple_occurrences(true)
.help("Additional arguments to pass to spamc"));
let socket = matches.value_of(ARG_SOCKET).unwrap();
let config = match build_config(&matches) {
let (socket, config) = match build_config(command) {
Ok(config) => config,
Err(e) => {
e.exit();
}
};
let (shutdown_tx, shutdown) = oneshot::channel();
let signals = Signals::new(&[SIGTERM, SIGINT]).expect("failed to install signal handler");
let signals_handle = signals.handle();
let signals_task = spawn_signals_task(signals, shutdown_tx);
let addr;
let mut socket_path = None;
let listener = match socket {
Socket::Inet(socket) => {
let listener = match TcpListener::bind(socket).await {
Ok(listener) => listener,
Err(e) => {
eprintln!("error: could not bind TCP socket: {}", e);
process::exit(1);
}
};
Listener::Tcp(listener)
}
Socket::Unix(socket) => {
// Before creating the socket file, try removing any existing socket
// at the target path. This is to clear out a leftover file from a
// previous, aborted execution.
try_remove_socket(&socket).await;
let listener = match UnixListener::bind(socket) {
Ok(listener) => listener,
Err(e) => {
eprintln!("error: could not create UNIX domain socket: {}", e);
process::exit(1);
}
};
// Remember the socket file path, and delete it on shutdown.
addr = listener.local_addr().unwrap();
socket_path = addr.as_pathname();
Listener::Unix(listener)
}
};
eprintln!("{} {} starting", MILTER_NAME, VERSION);
match spamassassin_milter::run(socket, config) {
Ok(_) => {
let result = spamassassin_milter::run(listener, config, shutdown).await;
cleanup(signals_handle, signals_task, socket_path).await;
match result {
Ok(()) => {
eprintln!("{} {} shut down", MILTER_NAME, VERSION);
}
Err(e) => {
@ -114,18 +159,50 @@ fn main() {
}
}
fn build_config(matches: &ArgMatches<'_>) -> Result<Config> {
enum Socket {
Inet(String),
Unix(String),
}
impl FromStr for Socket {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some(s) = s.strip_prefix("inet:") {
Ok(Self::Inet(s.into()))
} else if let Some(s) = s.strip_prefix("unix:") {
Ok(Self::Unix(s.into()))
} else {
Err(())
}
}
}
fn build_config(mut command: Command) -> clap::Result<(Socket, Config)> {
let matches = command.get_matches_mut();
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),
));
}
};
let mut config = Config::builder();
if let Some(bytes) = matches.value_of(ARG_MAX_MESSAGE_SIZE) {
match bytes.parse() {
Ok(bytes) => {
config.max_message_size(bytes);
config = config.max_message_size(bytes);
}
Err(_) => {
return Err(Error::with_description(
&format!("Invalid value for max message size: \"{}\"", bytes),
return Err(command.error(
ErrorKind::InvalidValue,
format!("Invalid value for max message size: \"{}\"", bytes),
));
}
}
@ -134,30 +211,33 @@ fn build_config(matches: &ArgMatches<'_>) -> Result<Config> {
if let Some(pos) = matches.value_of(ARG_SYNTH_RELAY) {
match pos.parse() {
Ok(pos) => {
config.synth_relay(pos);
config = config.synth_relay(pos);
}
Err(_) => {
return Err(Error::with_description(
&format!("Invalid value for synthesized relay header position: \"{}\"", pos),
return Err(command.error(
ErrorKind::InvalidValue,
format!("Invalid value for synthesized relay header position: \"{}\"", pos),
));
}
}
}
if let Some(nets) = matches.values_of(ARG_TRUSTED_NETWORKS) {
config.use_trusted_networks(true);
config = config.use_trusted_networks(true);
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)) {
match net
.parse()
.or_else(|_| net.parse::<IpAddr>().map(From::from))
{
Ok(net) => {
config.trusted_network(net);
config = config.trusted_network(net);
}
Err(_) => {
return Err(Error::with_description(
&format!("Invalid value for trusted network address: \"{}\"", net),
return Err(command.error(
ErrorKind::InvalidValue,
format!("Invalid value for trusted network address: \"{}\"", net),
));
}
}
@ -166,66 +246,101 @@ fn build_config(matches: &ArgMatches<'_>) -> Result<Config> {
let reply_code = matches.value_of(ARG_REPLY_CODE);
let reply_status_code = matches.value_of(ARG_REPLY_STATUS_CODE);
validate_reply_codes(reply_code, reply_status_code)?;
validate_reply_codes(&mut command, reply_code, reply_status_code)?;
if matches.is_present(ARG_AUTH_UNTRUSTED) {
config.auth_untrusted(true);
config = config.auth_untrusted(true);
}
if matches.is_present(ARG_DRY_RUN) {
config.dry_run(true);
config = config.dry_run(true);
}
if matches.is_present(ARG_PRESERVE_BODY) {
config.preserve_body(true);
config = config.preserve_body(true);
}
if matches.is_present(ARG_PRESERVE_HEADERS) {
config.preserve_headers(true);
config = config.preserve_headers(true);
}
if matches.is_present(ARG_REJECT_SPAM) {
config.reject_spam(true);
config = config.reject_spam(true);
}
if matches.is_present(ARG_VERBOSE) {
config.verbose(true);
config = config.verbose(true);
}
if let Some(code) = reply_code {
config.reply_code(code.to_owned());
config = config.reply_code(code.to_owned());
}
if let Some(code) = reply_status_code {
config.reply_status_code(code.to_owned());
config = config.reply_status_code(code.to_owned());
}
if let Some(msg) = matches.value_of(ARG_REPLY_TEXT) {
config.reply_text(msg.to_owned());
}
if let Some(level) = matches.value_of(ARG_MILTER_DEBUG_LEVEL) {
config.milter_debug_level(level.parse().unwrap());
config = config.reply_text(msg.to_owned());
}
if let Some(spamc_args) = matches.values_of(ARG_SPAMC_ARGS) {
config.spamc_args(spamc_args);
config = config.spamc_args(spamc_args);
};
Ok(config.build())
Ok((socket, config.build()))
}
fn validate_reply_codes(reply_code: Option<&str>, reply_status_code: Option<&str>) -> Result<()> {
fn validate_reply_codes(
command: &mut Command,
reply_code: Option<&str>,
reply_status_code: Option<&str>,
) -> clap::Result<()> {
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(Error::with_description(
&format!(
Err(command.error(
ErrorKind::InvalidValue,
format!(
"Invalid or incompatible values for reply code and status code: \"{}\", \"{}\"",
c1, c2
),
ErrorKind::InvalidValue,
))
}
(Some(c), None) if !c.starts_with('5') => Err(Error::with_description(
&format!("Invalid value for reply code (5XX): \"{}\"", c),
(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(Error::with_description(
&format!("Invalid value for reply status code (5.X.X): \"{}\"", 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(()),
}
}
fn spawn_signals_task(
mut signals: Signals,
shutdown_milter: oneshot::Sender<()>,
) -> JoinHandle<()> {
tokio::spawn(async move {
while let Some(signal) = signals.next().await {
match signal {
SIGINT | SIGTERM => {
let _ = shutdown_milter.send(());
break;
}
_ => panic!("unexpected signal"),
}
}
})
}
async fn cleanup(signals_handle: Handle, signals_task: JoinHandle<()>, socket_path: Option<&Path>) {
signals_handle.close();
signals_task.await.expect("signal handler task failed");
if let Some(p) = socket_path {
try_remove_socket(p).await;
}
}
async fn try_remove_socket(path: impl AsRef<Path>) {
if let Ok(metadata) = fs::metadata(&path).await {
if metadata.file_type().is_socket() {
let _ = fs::remove_file(path).await;
}
}
}

View file

@ -1,6 +1,6 @@
-- An authenticated sender is accepted, the message is not processed.
local conn = mt.connect("inet:3333@localhost")
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")

View file

@ -1,16 +1,16 @@
mod common;
pub use common::*;
use spamassassin_milter::*;
#[test]
fn authenticated_sender() {
let config = Default::default();
#[tokio::test]
async fn authenticated_sender() {
let milter = SpamAssassinMilter::spawn(LOCALHOST, Default::default())
.await
.unwrap();
let miltertest = spawn_miltertest_runner(file!());
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
run("inet:3333@localhost", config).expect("milter execution failed");
milter.shutdown().await.unwrap();
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
assert!(exit_code.success());
}

View file

@ -1,24 +1,32 @@
use spamassassin_milter::ConfigBuilder;
use spamassassin_milter::{Config, ConfigBuilder};
use std::{
ffi::OsString,
io::{ErrorKind, Read, Write},
net::{Ipv6Addr, Shutdown, SocketAddr, TcpListener},
io::{self, ErrorKind},
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
path::PathBuf,
process::{Command, ExitStatus},
thread::{self, JoinHandle},
time::{Duration, Instant},
process::ExitStatus,
time::Duration,
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, ToSocketAddrs},
process::Command,
sync::oneshot,
task::JoinHandle,
time,
};
pub const LOCALHOST: (Ipv4Addr, u16) = (Ipv4Addr::LOCALHOST, 0);
/// Configures the builder for integration testing with `spamc`. Most
/// importantly, this isolates `spamc` from any configuration file
/// `/etc/spamassassin/spamc.conf` present on the host, as this configuration is
/// read by default and may break the integration tests.
pub fn configure_spamc(mut builder: ConfigBuilder) -> ConfigBuilder {
pub fn configure_spamc(builder: ConfigBuilder) -> ConfigBuilder {
// Note: Must use `-F` instead of `--config` due to a bug in `spamc`.
// `--no-safe-fallback` prevents connection attempts from failing silently,
// and `--log-to-stderr` avoids polluting syslog with test output.
builder.spamc_args(&["-F", "/dev/null", "--no-safe-fallback", "--log-to-stderr"]);
builder
builder.spamc_args(&["-F", "/dev/null", "--no-safe-fallback", "--log-to-stderr"])
}
pub const SPAMD_PORT: u16 = 3783; // mock port
@ -28,43 +36,30 @@ pub type HamOrSpam = Result<String, String>;
/// Spawns a mock `spamd` server that echoes what it is sent after applying
/// transformation `f` to the message content and mock-classifying it as ham or
/// spam.
pub fn spawn_mock_spamd_server<F>(port: u16, f: F) -> JoinHandle<()>
pub async fn spawn_mock_spamd_server<F>(port: u16, f: F) -> io::Result<JoinHandle<io::Result<()>>>
where
F: Fn(String) -> HamOrSpam + Send + 'static,
{
let socket_addr = SocketAddr::new(Ipv6Addr::LOCALHOST.into(), port);
let timeout = Duration::from_secs(15);
thread::spawn(move || {
let listener = TcpListener::bind(socket_addr).unwrap();
listener.set_nonblocking(true).unwrap();
let now = Instant::now();
let socket_addr = (Ipv6Addr::LOCALHOST, port);
let listener = TcpListener::bind(socket_addr).await?;
Ok(tokio::spawn(async move {
// This server expects and handles only a single connection, so that we
// can `join` this thread in the tests and detect panics. A panic can be
// triggered both in the handling code as well as due to the timeout.
loop {
match listener.accept() {
Ok((mut stream, _)) => {
let mut buf = Vec::new();
stream.read_to_end(&mut buf).unwrap();
stream.write_all(process_message(buf, &f).as_bytes()).unwrap();
stream.shutdown(Shutdown::Write).unwrap();
break;
}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
if now.elapsed() > timeout {
panic!("mock spamd server timed out waiting for a connection");
}
}
Err(e) => {
panic!("mock spamd server could not open connection: {}", e);
}
}
}
})
// can `join` this task in the tests and detect errors and panics.
let (mut stream, _) = time::timeout(Duration::from_secs(10), listener.accept())
.await
.map_err(|e| io::Error::new(ErrorKind::Other, e))??;
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await?;
let msg = process_message(buf, &f);
stream.write_all(msg.as_bytes()).await?;
Ok(())
}))
}
// The SpamAssassin client/server protocol is here reverse-engineered in a very
@ -76,7 +71,7 @@ const SPAMD_PROTOCOL_OK: &str = "SPAMD/1.1 0 EX_OK";
fn process_message<F>(buf: Vec<u8>, f: &F) -> String
where
F: Fn(String) -> HamOrSpam,
F: Fn(String) -> HamOrSpam + Send + 'static,
{
let mut msg = String::from_utf8(buf).unwrap();
@ -104,38 +99,55 @@ where
}
}
const MILTERTEST_PROGRAM: &str = "miltertest";
pub struct SpamAssassinMilter {
milter_handle: JoinHandle<io::Result<()>>,
shutdown: oneshot::Sender<()>,
addr: SocketAddr,
}
pub fn spawn_miltertest_runner(test_file_name: &str) -> JoinHandle<ExitStatus> {
// This thread is just for safety, in case the miltertest runner thread
// below never manages to shut down the milter.
let _timeout_thread = thread::spawn(|| {
thread::sleep(Duration::from_secs(20));
impl SpamAssassinMilter {
pub async fn spawn(addr: impl ToSocketAddrs, config: Config) -> io::Result<Self> {
let listener = TcpListener::bind(addr).await?;
eprintln!("miltertest runner timed out");
let addr = listener.local_addr()?;
milter::shutdown();
});
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let milter = tokio::spawn(spamassassin_milter::run(listener, config, shutdown_rx));
Ok(Self {
milter_handle: milter,
shutdown: shutdown_tx,
addr,
})
}
pub fn addr(&self) -> SocketAddr {
self.addr
}
pub async fn shutdown(self) -> io::Result<()> {
let _ = self.shutdown.send(());
self.milter_handle.await?
}
}
const MILTERTEST_PROGRAM: &str = "/usr/bin/miltertest";
pub async fn run_miltertest(test_file_name: &str, addr: SocketAddr) -> io::Result<ExitStatus> {
let file_name = to_miltertest_file_name(test_file_name);
let port = addr.port();
thread::spawn(move || {
// Wait just a little while to give the milter time to start up.
thread::sleep(Duration::from_millis(100));
let mut miltertest = Command::new(MILTERTEST_PROGRAM)
// .arg("-vvv")
.arg("-D")
.arg(format!("port={}", port))
.arg("-s")
.arg(&file_name)
.spawn()?;
let output = Command::new(MILTERTEST_PROGRAM)
.arg("-s")
.arg(&file_name)
.output()
.expect("miltertest execution failed");
print_output_stream("STDOUT", output.stdout);
print_output_stream("STDERR", output.stderr);
milter::shutdown();
output.status
})
miltertest.wait().await
}
fn to_miltertest_file_name(file_name: &str) -> OsString {
@ -143,17 +155,3 @@ fn to_miltertest_file_name(file_name: &str) -> OsString {
path.set_extension("lua");
path.into_os_string()
}
fn print_output_stream(name: &str, output: Vec<u8>) {
if !output.is_empty() {
let output = String::from_utf8(output).unwrap();
eprintln!("{}:", name);
if output.ends_with('\n') {
eprint!("{}", &output)
} else {
eprintln!("{}", &output)
}
}
}

View file

@ -1,6 +1,6 @@
-- Happy path processing of an ordinary ham (not spam) message.
local conn = mt.connect("inet:3333@localhost")
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")

View file

@ -3,11 +3,11 @@ mod common;
pub use common::*;
use spamassassin_milter::*;
#[test]
fn ham_message() {
let mut builder = configure_spamc(Config::builder());
builder.spamc_args(&[format!("--port={}", SPAMD_PORT)]);
let config = builder.build();
#[tokio::test]
async fn ham_message() {
let config = configure_spamc(Config::builder())
.spamc_args(&[format!("--port={}", SPAMD_PORT)])
.build();
let server = spawn_mock_spamd_server(SPAMD_PORT, |ham| {
let mut ham = ham
@ -22,14 +22,16 @@ fn ham_message() {
);
Ok(ham)
});
})
.await
.unwrap();
let miltertest = spawn_miltertest_runner(file!());
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
milter.shutdown().await.unwrap();
server.await.unwrap().unwrap();
server.join().expect("panic in mock spamd server");
assert!(exit_code.success());
}

View file

@ -1,6 +1,6 @@
-- Live test against a real SpamAssassin server.
local conn = mt.connect("inet:3333@localhost")
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")

View file

@ -6,17 +6,18 @@ use spamassassin_milter::*;
/// Runs a live test against a real SpamAssassin server instance. This test is
/// run on demand, as SpamAssassin will actually analyse the input, and do DNS
/// queries etc.
#[test]
#[ignore]
fn live() {
#[tokio::test]
async fn live() {
// When no port is specified, `spamc` will try to connect to the default
// `spamd` port 783 (see also `/etc/services`).
let config = configure_spamc(Config::builder()).build();
let miltertest = spawn_miltertest_runner(file!());
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
milter.shutdown().await.unwrap();
assert!(exit_code.success());
}

View file

@ -1,6 +1,6 @@
-- 1) A connection from the loopback IP address is accepted.
local conn = mt.connect("inet:3333@localhost")
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, nil, "127.0.0.1")
@ -13,7 +13,7 @@ assert(err == nil, err)
-- 2) A connection from an unknown IP address (for example, from a UNIX
-- domain socket) is also accepted.
local conn = mt.connect("inet:3333@localhost")
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, nil, "unspec")

View file

@ -1,16 +1,16 @@
mod common;
pub use common::*;
use spamassassin_milter::*;
#[test]
fn loopback_connection() {
let config = Default::default();
#[tokio::test]
async fn loopback_connection() {
let milter = SpamAssassinMilter::spawn(LOCALHOST, Default::default())
.await
.unwrap();
let miltertest = spawn_miltertest_runner(file!());
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
run("inet:3333@localhost", config).expect("milter execution failed");
milter.shutdown().await.unwrap();
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
assert!(exit_code.success());
}

View file

@ -1,6 +1,6 @@
-- A spam message is rejected with an SMTP error reply.
local conn = mt.connect("inet:3333@localhost")
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")

View file

@ -3,26 +3,27 @@ mod common;
pub use common::*;
use spamassassin_milter::*;
#[test]
fn reject_spam() {
let mut builder = configure_spamc(Config::builder());
builder
#[tokio::test]
async fn reject_spam() {
let config = configure_spamc(Config::builder())
.reject_spam(true)
.reply_code("554".into())
.reply_text("Not allowed!".into())
.spamc_args(&[format!("--port={}", SPAMD_PORT)]);
let config = builder.build();
.spamc_args(&[format!("--port={}", SPAMD_PORT)])
.build();
let server = spawn_mock_spamd_server(SPAMD_PORT, |spam| {
Err(spam.replacen("\r\n\r\n", "\r\nX-Spam-Flag: YES\r\n\r\n", 1))
});
})
.await
.unwrap();
let miltertest = spawn_miltertest_runner(file!());
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
milter.shutdown().await.unwrap();
server.await.unwrap().unwrap();
server.join().expect("panic in mock spamd server");
assert!(exit_code.success());
}

View file

@ -2,7 +2,7 @@
-- reached, the rest is skipped (oversized messages are not processed by
-- SpamAssassin, so it is futile to send the whole message in this case).
local conn = mt.connect("inet:3333@localhost")
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")

View file

@ -3,21 +3,21 @@ mod common;
pub use common::*;
use spamassassin_milter::*;
#[test]
fn skip_oversized() {
let mut builder = configure_spamc(Config::builder());
builder
#[tokio::test]
async fn skip_oversized() {
let config = configure_spamc(Config::builder())
.max_message_size(512)
.spamc_args(&[format!("--port={}", SPAMD_PORT)]);
let config = builder.build();
.spamc_args(&[format!("--port={}", SPAMD_PORT)])
.build();
let server = spawn_mock_spamd_server(SPAMD_PORT, Ok);
let miltertest = spawn_miltertest_runner(file!());
let server = spawn_mock_spamd_server(SPAMD_PORT, Ok).await.unwrap();
run("inet:3333@localhost", config).expect("milter execution failed");
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
server.join().expect("panic in mock spamd server");
milter.shutdown().await.unwrap();
server.await.unwrap().unwrap();
assert!(exit_code.success());
}

View file

@ -1,6 +1,6 @@
-- Happy path processing of a spam message.
local conn = mt.connect("inet:3333@localhost")
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")

View file

@ -3,11 +3,11 @@ mod common;
pub use common::*;
use spamassassin_milter::*;
#[test]
fn spam_message() {
let mut builder = configure_spamc(Config::builder());
builder.spamc_args(&[format!("--port={}", SPAMD_PORT)]);
let config = builder.build();
#[tokio::test]
async fn spam_message() {
let config = configure_spamc(Config::builder())
.spamc_args(&[format!("--port={}", SPAMD_PORT)])
.build();
let server = spawn_mock_spamd_server(SPAMD_PORT, |spam| {
let mut spam = spam
@ -36,14 +36,16 @@ fn spam_message() {
);
Err(spam)
});
})
.await
.unwrap();
let miltertest = spawn_miltertest_runner(file!());
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
milter.shutdown().await.unwrap();
server.await.unwrap().unwrap();
server.join().expect("panic in mock spamd server");
assert!(exit_code.success());
}

View file

@ -1,6 +1,6 @@
-- When no `spamd` server is available, `spamc` fails to connect.
local conn = mt.connect("inet:3333@localhost")
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")

View file

@ -3,16 +3,17 @@ mod common;
pub use common::*;
use spamassassin_milter::*;
#[test]
fn spamc_connection_error() {
let mut builder = configure_spamc(Config::builder());
builder.spamc_args(&[format!("--port={}", SPAMD_PORT)]);
let config = builder.build();
#[tokio::test]
async fn spamc_connection_error() {
let config = configure_spamc(Config::builder())
.spamc_args(&[format!("--port={}", SPAMD_PORT)])
.build();
let miltertest = spawn_miltertest_runner(file!());
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
milter.shutdown().await.unwrap();
assert!(exit_code.success());
}

View file

@ -1,6 +1,6 @@
-- A connection from a trusted network is accepted.
local conn = mt.connect("inet:3333@localhost")
local conn = mt.connect("inet:" .. port .. "@127.0.0.1")
assert(conn, "could not open connection")
local err = mt.conninfo(conn, "client.gluet.ch", "123.123.123.123")

View file

@ -3,16 +3,17 @@ mod common;
pub use common::*;
use spamassassin_milter::*;
#[test]
fn trusted_network_connection() {
let mut builder = Config::builder();
builder.trusted_network("123.120.0.0/14".parse().unwrap());
let config = builder.build();
#[tokio::test]
async fn trusted_network_connection() {
let config = Config::builder()
.trusted_network("123.120.0.0/14".parse().unwrap())
.build();
let miltertest = spawn_miltertest_runner(file!());
let milter = SpamAssassinMilter::spawn(LOCALHOST, config).await.unwrap();
run("inet:3333@localhost", config).expect("milter execution failed");
let exit_code = run_miltertest(file!(), milter.addr()).await.unwrap();
let exit_code = miltertest.join().expect("panic in miltertest runner");
assert!(exit_code.success(), "miltertest returned error exit code");
milter.shutdown().await.unwrap();
assert!(exit_code.success());
}