Commit
+228 -36 +/-7 browse
1 | diff --git a/Cargo.lock b/Cargo.lock |
2 | index 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 |
139 | index 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 |
148 | index 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 |
248 | index 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 |
287 | new file mode 100644 |
288 | index 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 |
381 | index 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 |
430 | index 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 | + } |