Commit
Author: Kevin Schoon [me@kevinschoon.com]
Hash: a8a48a8b81378b3435dadd6fc303da8d00467924
Timestamp: Sun, 23 Jun 2024 11:51:25 +0000 (9 months ago)

+228 -36 +/-7 browse
add support for ngx_mail_auth_http_module
add support for ngx_mail_auth_http_module

This adds an implementation of the ngx_mail_auth_http_module [1] proxy
protocol so that Nginx can be used as a reverse proxy for gating SMTP
requests to Postfix or another email server. If mail.nginx_auth.domains
is enabled then only e-mails sent to the configured domains will be
authorized by the server.

If desired in the future the authentication protocol can be expanded to support
SASL authentication [2] and support authenticated mailing list use which might
be useful in certain scenarios.

1. https://nginx.org/en/docs/mail/ngx_mail_auth_http_module.html#protocol
2. https://nginx.org/en/docs/mail/ngx_mail_smtp_module.html#smtp_auth
1diff --git a/Cargo.lock b/Cargo.lock
2index 65845cf..80e6320 100644
3--- a/Cargo.lock
4+++ b/Cargo.lock
5 @@ -398,9 +398,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
6
7 [[package]]
8 name = "axum"
9- version = "0.7.4"
10+ version = "0.7.5"
11 source = "registry+https://github.com/rust-lang/crates.io-index"
12- checksum = "1236b4b292f6c4d6dc34604bb5120d85c3fe1d1aa596bd5cc52ca054d13e7b9e"
13+ checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf"
14 dependencies = [
15 "async-trait",
16 "axum-core",
17 @@ -410,7 +410,7 @@ dependencies = [
18 "http 1.1.0",
19 "http-body 1.0.0",
20 "http-body-util",
21- "hyper 1.2.0",
22+ "hyper 1.3.1",
23 "hyper-util",
24 "itoa",
25 "matchit",
26 @@ -423,7 +423,7 @@ dependencies = [
27 "serde_json",
28 "serde_path_to_error",
29 "serde_urlencoded",
30- "sync_wrapper",
31+ "sync_wrapper 1.0.1",
32 "tokio",
33 "tower",
34 "tower-layer",
35 @@ -446,7 +446,7 @@ dependencies = [
36 "mime",
37 "pin-project-lite",
38 "rustversion",
39- "sync_wrapper",
40+ "sync_wrapper 0.1.2",
41 "tower-layer",
42 "tower-service",
43 "tracing",
44 @@ -551,6 +551,7 @@ name = "ayllu-mail"
45 version = "0.2.1"
46 dependencies = [
47 "anyhow",
48+ "axum",
49 "ayllu_api",
50 "ayllu_config",
51 "ayllu_rpc",
52 @@ -2270,12 +2271,12 @@ dependencies = [
53
54 [[package]]
55 name = "http-body-util"
56- version = "0.1.1"
57+ version = "0.1.2"
58 source = "registry+https://github.com/rust-lang/crates.io-index"
59- checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d"
60+ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f"
61 dependencies = [
62 "bytes",
63- "futures-core",
64+ "futures-util",
65 "http 1.1.0",
66 "http-body 1.0.0",
67 "pin-project-lite",
68 @@ -2334,9 +2335,9 @@ dependencies = [
69
70 [[package]]
71 name = "hyper"
72- version = "1.2.0"
73+ version = "1.3.1"
74 source = "registry+https://github.com/rust-lang/crates.io-index"
75- checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a"
76+ checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d"
77 dependencies = [
78 "bytes",
79 "futures-channel",
80 @@ -2374,7 +2375,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
81 dependencies = [
82 "bytes",
83 "http-body-util",
84- "hyper 1.2.0",
85+ "hyper 1.3.1",
86 "hyper-util",
87 "native-tls",
88 "tokio",
89 @@ -2393,7 +2394,7 @@ dependencies = [
90 "futures-util",
91 "http 1.1.0",
92 "http-body 1.0.0",
93- "hyper 1.2.0",
94+ "hyper 1.3.1",
95 "pin-project-lite",
96 "socket2 0.5.6",
97 "tokio",
98 @@ -4087,7 +4088,7 @@ dependencies = [
99 "serde",
100 "serde_json",
101 "serde_urlencoded",
102- "sync_wrapper",
103+ "sync_wrapper 0.1.2",
104 "system-configuration",
105 "tokio",
106 "tokio-native-tls",
107 @@ -4114,7 +4115,7 @@ dependencies = [
108 "http 1.1.0",
109 "http-body 1.0.0",
110 "http-body-util",
111- "hyper 1.2.0",
112+ "hyper 1.3.1",
113 "hyper-tls 0.6.0",
114 "hyper-util",
115 "ipnet",
116 @@ -4129,7 +4130,7 @@ dependencies = [
117 "serde",
118 "serde_json",
119 "serde_urlencoded",
120- "sync_wrapper",
121+ "sync_wrapper 0.1.2",
122 "system-configuration",
123 "tokio",
124 "tokio-native-tls",
125 @@ -4946,6 +4947,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
126 checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
127
128 [[package]]
129+ name = "sync_wrapper"
130+ version = "1.0.1"
131+ source = "registry+https://github.com/rust-lang/crates.io-index"
132+ checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
133+
134+ [[package]]
135 name = "syntect"
136 version = "5.2.0"
137 source = "registry+https://github.com/rust-lang/crates.io-index"
138 diff --git a/ayllu-mail/Cargo.toml b/ayllu-mail/Cargo.toml
139index fc76b0a..23aeac1 100644
140--- a/ayllu-mail/Cargo.toml
141+++ b/ayllu-mail/Cargo.toml
142 @@ -21,3 +21,4 @@ melib = "0.8.2"
143 mailpot = { git = "https://ayllu-forge.org/ayllu/mailpot", branch = "main"}
144 clap_complete = "4.4.5"
145 anyhow = "1.0.78"
146+ axum = "0.7.5"
147 diff --git a/ayllu-mail/src/config.rs b/ayllu-mail/src/config.rs
148index 901c169..3447513 100644
149--- a/ayllu-mail/src/config.rs
150+++ b/ayllu-mail/src/config.rs
151 @@ -66,9 +66,66 @@ pub struct MailingList {
152 pub subscription_policy: SubscriptionPolicy,
153 }
154
155- /// Postfix related configuration mostly used at container initialization
156- #[derive(Serialize, Deserialize, Clone)]
157- pub struct Postfix {}
158+ #[derive(Deserialize, Serialize, Clone, Debug)]
159+ pub struct Http {
160+ #[serde(default = "Http::default_address")]
161+ pub address: String,
162+ }
163+
164+ impl Http {
165+ fn default_address() -> String {
166+ String::from("127.0.0.1:32001")
167+ }
168+ }
169+
170+ impl Default for Http {
171+ fn default() -> Self {
172+ Http {
173+ address: Http::default_address(),
174+ }
175+ }
176+ }
177+
178+ #[derive(Deserialize, Serialize, Clone, Debug)]
179+ pub struct NginxAuth {
180+ /// address to listen on for ngx_mail_auth_http_module requests
181+ #[serde(default = "NginxAuth::default_address")]
182+ pub listen: String,
183+ /// hostname of your smtp server
184+ #[serde(default = "NginxAuth::default_host")]
185+ pub host: String,
186+ /// port your smtp server is listening on
187+ #[serde(default = "NginxAuth::default_port")]
188+ pub port: u16,
189+ /// An array of domains that the mail server may accept mail for, if not
190+ /// specified mail to any domain will be accepted.
191+ pub domains: Option<Vec<String>>,
192+ }
193+
194+ impl NginxAuth {
195+ fn default_host() -> String {
196+ String::from("127.0.0.1")
197+ }
198+
199+ fn default_port() -> u16 {
200+ 2525
201+ }
202+
203+ fn default_address() -> String {
204+ String::from("127.0.0.1:32001")
205+ }
206+ }
207+
208+ impl Default for NginxAuth {
209+ fn default() -> Self {
210+ NginxAuth {
211+ host: NginxAuth::default_host(),
212+ port: NginxAuth::default_port(),
213+ listen: NginxAuth::default_address(),
214+ domains: None,
215+ }
216+ }
217+ }
218
219 #[derive(Serialize, Deserialize, Clone)]
220 pub struct Mail {
221 @@ -83,6 +140,8 @@ pub struct Mail {
222 pub postfix_user: String,
223 #[serde(default = "Mail::default_postfix_user")]
224 pub postfix_group: String,
225+ #[serde(default = "NginxAuth::default")]
226+ pub nginx_auth: NginxAuth,
227 }
228
229 impl Mail {
230 @@ -108,6 +167,7 @@ impl Default for Mail {
231 lists: Vec::new(),
232 postfix_user: Mail::default_postfix_user(),
233 postfix_group: Mail::default_postfix_user(),
234+ nginx_auth: NginxAuth::default(),
235 }
236 }
237 }
238 @@ -118,6 +178,8 @@ pub struct Config {
239 pub log_level: String,
240 #[serde(default = "Mail::default")]
241 pub mail: Mail,
242+ #[serde(default = "NginxAuth::default")]
243+ pub smtp: NginxAuth,
244 }
245
246 impl Config {
247 diff --git a/ayllu-mail/src/main.rs b/ayllu-mail/src/main.rs
248index 1cf9bbf..64936dd 100644
249--- a/ayllu-mail/src/main.rs
250+++ b/ayllu-mail/src/main.rs
251 @@ -19,6 +19,8 @@ use tracing::{log, Level};
252
253 mod config;
254 mod declarative;
255+ /// implements https://nginx.org/en/docs/mail/ngx_mail_auth_http_module.html#protocol
256+ mod nginx_auth;
257 mod server;
258
259 #[derive(Parser)]
260 @@ -111,15 +113,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
261 cli.level
262 .unwrap_or(Level::from_str(&ayllu_config.log_level)?),
263 );
264+ let cfg = ayllu_config.clone();
265+ let http_cfg = ayllu_config.clone();
266+ std::thread::spawn(move || {
267+ TokioBuilder::new_current_thread()
268+ .enable_all()
269+ .build()
270+ .unwrap()
271+ .block_on(async move { nginx_auth::serve(http_cfg).await });
272+ });
273 TokioBuilder::new_current_thread()
274 .enable_all()
275 .build()
276 .unwrap()
277- .block_on(async move {
278- LocalSet::new()
279- .run_until(server::serve(&ayllu_config))
280- .await
281- })?;
282+ .block_on(async move { LocalSet::new().run_until(server::serve(&cfg)).await })?;
283 }
284 Commands::Post {} => {
285 init_logger(
286 diff --git a/ayllu-mail/src/nginx_auth.rs b/ayllu-mail/src/nginx_auth.rs
287new file mode 100644
288index 0000000..f4fad42
289--- /dev/null
290+++ b/ayllu-mail/src/nginx_auth.rs
291 @@ -0,0 +1,88 @@
292+ use std::sync::Arc;
293+
294+ use axum::{
295+ body::Body,
296+ extract::{Request, State},
297+ http::{header::ToStrError, HeaderValue, StatusCode},
298+ response::Response,
299+ routing::get,
300+ Router,
301+ };
302+ use melib::email::parser::address::address;
303+
304+ use crate::config::Config;
305+
306+ fn to_str_err(e: ToStrError) -> StatusCode {
307+ tracing::error!("error reading header: {}", e.to_string());
308+ StatusCode::BAD_REQUEST
309+ }
310+
311+ async fn handle(
312+ State(cfg): State<Arc<Config>>,
313+ req: Request,
314+ ) -> Result<Response<Body>, StatusCode> {
315+ tracing::info!("processing new SMTP authentication request with headers:");
316+ req.headers().iter().for_each(|(key, value)| {
317+ tracing::info!("{:?}: {:?}", key, value);
318+ });
319+ let auth_cfg = cfg.mail.nginx_auth.clone();
320+ let domains = auth_cfg.domains.unwrap_or(Vec::new());
321+ if let Some(helo) = req.headers().get("auth-smtp-helo") {
322+ let helo = helo.to_str().map_err(to_str_err)?;
323+ if !domains.is_empty() && !domains.contains(&helo.to_string()) {
324+ tracing::warn!("domain {} is not authroized", helo);
325+ return Err(StatusCode::UNAUTHORIZED);
326+ }
327+ } else {
328+ tracing::warn!("missing auth-smtp-helo header");
329+ };
330+ if let Some(to) = req.headers().get("auth-smtp-to") {
331+ let recipient = to.to_str().map_err(to_str_err)?;
332+ let recipient = recipient.trim_start_matches("RCPT TO:");
333+ let recipient_addr = address(recipient.as_bytes()).map_err(|e| {
334+ tracing::warn!("cannot parse mail address: {:?}", e);
335+ StatusCode::BAD_REQUEST
336+ })?;
337+ let recipient_addr = recipient_addr.1;
338+ if let Some(fqdn) = recipient_addr.get_fqdn() {
339+ if !domains.is_empty() && !domains.contains(&fqdn) {
340+ tracing::warn!("mail is not permitted to be sent to domain: {}", fqdn);
341+ return Err(StatusCode::UNAUTHORIZED);
342+ }
343+ } else {
344+ tracing::warn!("recipient address has not FQDN");
345+ return Err(StatusCode::BAD_REQUEST);
346+ }
347+ } else {
348+ tracing::warn!("missing auth-smtp-to header");
349+ return Err(StatusCode::BAD_REQUEST);
350+ }
351+ let mut res = Response::new(Body::empty());
352+ let headers = res.headers_mut();
353+ headers.insert("Auth-Status", HeaderValue::from_static("OK"));
354+ headers.insert(
355+ "Auth-Server",
356+ HeaderValue::from_str(&cfg.smtp.host).expect("invalid downstream smtp server"),
357+ );
358+ headers.insert(
359+ "Auth-Port",
360+ HeaderValue::from_str(&cfg.smtp.port.to_string()).unwrap(),
361+ );
362+ Ok(res)
363+ }
364+
365+ /// Simple HTTP server implementing Nginx's ngx_mail_auth_http protocol
366+ /// https://nginx.org/en/docs/mail/ngx_mail_auth_http_module.html#protocol
367+ /// which is used to authenticate and redirect requests to an e-mail server.
368+ /// The primary use case for this is to avoid exposing Postfix directly to
369+ /// the internet and proxy SMTP requests via Nginx. This also enables
370+ /// termination of TLS with Nginx.
371+ pub async fn serve(cfg: Config) {
372+ let listen_addr = cfg.mail.nginx_auth.listen.clone();
373+ let app = Router::new()
374+ .route("/", get(handle))
375+ .with_state(Arc::new(cfg.clone()));
376+ tracing::info!("nginx_auth module listing @ {}", listen_addr);
377+ let listener = tokio::net::TcpListener::bind(listen_addr).await.unwrap();
378+ axum::serve(listener, app).await.unwrap();
379+ }
380 diff --git a/config.example.toml b/config.example.toml
381index e14e556..7066083 100644
382--- a/config.example.toml
383+++ b/config.example.toml
384 @@ -259,20 +259,37 @@ migrate = true
385
386 # mailing list support with mailpot, if unspecified no mailing list pages will
387 # be visible in the web application.
388- # [mail]
389+ [mail]
390 # command used to send an e-mail
391- # sendmail_command = "/usr/bin/false"
392+ sendmail_command = "/usr/bin/false"
393 # socket path for communicating with the mail server. This will default to your
394 # XDG_RUNTIME_DIR or /tmp/ayllu-mail.sock
395 # socket_path = /var/run/user/1000/ayllu-mail.sock
396
397+ # Implements nginx_mail_auth_http protocol
398+ # https://nginx.org/en/docs/mail/ngx_mail_auth_http_module.html#protocol
399+ # to gate SMTP requests via Nginx
400+ [mail.nginx_auth]
401+ # address to listen for Nginx authentication requests from
402+ listen = "127.0.0.1:32001"
403+ # downstream SMTP server address
404+ host = "127.0.0.1"
405+ # downstream SMTP server port
406+ port = 25
407+ # fully qualified domains to accept e-mail for
408+ domains = ["ayllu-dev.local"]
409+
410 # mailing lists to configure and automatically accept e-mail for
411- # [[mail.lists]]
412+ [[mail.lists]]
413 # unique identifier across all mailing lists
414- # id = "hello-world"
415+ id = "hello"
416 # fully qualified email address where the mailing list lives
417- # address = "hello@ayllu-forge.org"
418+ address = "hello@ayllu-dev.local"
419 # friendly description
420- # description = "an illistrative mailing list"
421+ description = "an illistrative mailing list"
422 # free-form string tags to specify the purpose of the mailing list
423- # topics = ["fuu", "bar"]
424+ topics = ["fuu", "bar"]
425+ # mailing list post policy
426+ post_policy = "Open"
427+ # mailing list subscription policy
428+ subscription_policy = {"send_confirmation" = true, "kind" = "Open"}
429 diff --git a/contrib/nginx/nginx.conf b/contrib/nginx/nginx.conf
430index f38d5dd..a5429c5 100644
431--- a/contrib/nginx/nginx.conf
432+++ b/contrib/nginx/nginx.conf
433 @@ -26,7 +26,8 @@ http {
434 include config.d/security.conf;
435 include config.d/general.conf;
436
437-
438+ # project redirects can be implemented like:
439+ # TODO: just implement this directly in the ayllu HTTP server
440 location /projects/ayllu {
441 return 301 $scheme://$host/ayllu/ayllu;
442 }
443 @@ -84,11 +85,6 @@ http {
444 include config.d/security.conf;
445 include config.d/general.conf;
446
447-
448- location /projects/ayllu {
449- return 301 $scheme://$host/ayllu/ayllu;
450- }
451-
452 location / {
453 include config.d/proxy.conf;
454 proxy_pass http://127.0.0.1:8080;
455 @@ -100,3 +96,17 @@ http {
456 }
457 }
458 }
459+
460+ mail {
461+ server_name ayllu-dev.local;
462+ auth_http http://127.0.0.1:32001;
463+
464+ proxy_pass_error_message on;
465+ xclient off;
466+
467+ server {
468+ listen 2225;
469+ protocol smtp;
470+ smtp_auth none;
471+ }
472+ }