1
0
mirror of https://github.com/Mailu/Mailu.git synced 2025-08-10 22:31:47 +02:00
2790: Implement managesieve support r=mergify[bot] a=nextgens

## What type of PR?

Feature

## What does this PR do?

This is a better a alternative to #2773

Expose managesieve to the outside world.

### Related issue(s)
- close #2773
- #428
- #113
- #81
- #1222

## Prerequisites
Before we can consider review and merge, please make sure the following list is done and checked.
If an entry in not applicable, you can check it or remove it from the list.

- [x] In case of feature or enhancement: documentation updated accordingly
- [x] Unless it's docs or a minor change: add [changelog](https://mailu.io/master/contributors/workflow.html#changelog) entry file.


Co-authored-by: Florent Daigniere <nextgens@freenetproject.org>
This commit is contained in:
bors[bot]
2023-05-26 14:11:33 +00:00
committed by GitHub
19 changed files with 181 additions and 44 deletions

View File

@@ -20,7 +20,7 @@ Main features include:
- **Standard email server**, IMAP and IMAP+, SMTP and Submission with autoconfiguration profiles for clients - **Standard email server**, IMAP and IMAP+, SMTP and Submission with autoconfiguration profiles for clients
- **Advanced email features**, aliases, domain aliases, custom routing - **Advanced email features**, aliases, domain aliases, custom routing
- **Web access**, multiple Webmails and administration interface - **Web access**, multiple Webmails and administration interface
- **User features**, aliases, auto-reply, auto-forward, fetched accounts - **User features**, aliases, auto-reply, auto-forward, fetched accounts, managesieve
- **Admin features**, global admins, announcements, per-domain delegation, quotas - **Admin features**, global admins, announcements, per-domain delegation, quotas
- **Security**, enforced TLS, DANE, MTA-STS, Letsencrypt!, outgoing DKIM, anti-virus scanner, [Snuffleupagus](https://github.com/jvoisin/snuffleupagus/), block malicious attachments - **Security**, enforced TLS, DANE, MTA-STS, Letsencrypt!, outgoing DKIM, anti-virus scanner, [Snuffleupagus](https://github.com/jvoisin/snuffleupagus/), block malicious attachments
- **Antispam**, auto-learn, greylisting, DMARC and SPF, anti-spoofing - **Antispam**, auto-learn, greylisting, DMARC and SPF, anti-spoofing

View File

@@ -13,7 +13,8 @@ STATUSES = {
"authentication": ("Authentication credentials invalid", { "authentication": ("Authentication credentials invalid", {
"imap": "AUTHENTICATIONFAILED", "imap": "AUTHENTICATIONFAILED",
"smtp": "535 5.7.8", "smtp": "535 5.7.8",
"pop3": "-ERR Authentication failed" "pop3": "-ERR Authentication failed",
"sieve": "AuthFailed"
}), }),
"encryption": ("Must issue a STARTTLS command first", { "encryption": ("Must issue a STARTTLS command first", {
"smtp": "530 5.7.0" "smtp": "530 5.7.0"
@@ -25,7 +26,7 @@ STATUSES = {
}), }),
} }
WEBMAIL_PORTS = ['10143', '10025'] WEBMAIL_PORTS = ['14190', '10143', '10025']
def check_credentials(user, password, ip, protocol=None, auth_port=None): def check_credentials(user, password, ip, protocol=None, auth_port=None):
if not user or not user.enabled or (protocol == "imap" and not user.enable_imap and not auth_port in WEBMAIL_PORTS) or (protocol == "pop3" and not user.enable_pop): if not user or not user.enabled or (protocol == "imap" and not user.enable_imap and not auth_port in WEBMAIL_PORTS) or (protocol == "pop3" and not user.enable_pop):
@@ -49,8 +50,8 @@ def handle_authentication(headers):
""" Handle an HTTP nginx authentication request """ Handle an HTTP nginx authentication request
See: http://nginx.org/en/docs/mail/ngx_mail_auth_http_module.html#protocol See: http://nginx.org/en/docs/mail/ngx_mail_auth_http_module.html#protocol
""" """
method = headers["Auth-Method"] method = headers["Auth-Method"].lower()
protocol = headers["Auth-Protocol"] protocol = headers["Auth-Protocol"].lower()
# Incoming mail, no authentication # Incoming mail, no authentication
if method == "none" and protocol == "smtp": if method == "none" and protocol == "smtp":
server, port = get_server(protocol, False) server, port = get_server(protocol, False)
@@ -120,7 +121,7 @@ def handle_authentication(headers):
"Auth-Wait": 0 "Auth-Wait": 0
} }
# Unexpected # Unexpected
return {} raise Exception("SHOULD NOT HAPPEN")
def get_status(protocol, status): def get_status(protocol, status):
@@ -139,6 +140,8 @@ def get_server(protocol, authenticated=False):
hostname, port = app.config['SMTP_ADDRESS'], 10025 hostname, port = app.config['SMTP_ADDRESS'], 10025
else: else:
hostname, port = app.config['SMTP_ADDRESS'], 25 hostname, port = app.config['SMTP_ADDRESS'], 25
elif protocol == "sieve":
hostname, port = app.config['IMAP_ADDRESS'], 4190
try: try:
# test if hostname is already resolved to an ip address # test if hostname is already resolved to an ip address
ipaddress.ip_address(hostname) ipaddress.ip_address(hostname)

View File

@@ -12,6 +12,7 @@ default_login_user = mail
default_internal_group = dovecot default_internal_group = dovecot
haproxy_trusted_networks = {{ SUBNET }} {{ SUBNET6 }} haproxy_trusted_networks = {{ SUBNET }} {{ SUBNET6 }}
login_trusted_networks = {{ SUBNET }} {{ SUBNET6 }}
############### ###############
# Mailboxes # Mailboxes
@@ -142,7 +143,6 @@ service lmtp {
service managesieve-login { service managesieve-login {
inet_listener sieve { inet_listener sieve {
port = 4190 port = 4190
haproxy = yes
} }
} }

View File

@@ -17,18 +17,18 @@ ARG VERSION
LABEL version=$VERSION LABEL version=$VERSION
RUN set -euxo pipefail \ RUN set -euxo pipefail \
; apk add --no-cache certbot nginx nginx-mod-http-brotli nginx-mod-stream nginx-mod-mail openssl \ ; apk add --no-cache certbot nginx nginx-mod-http-brotli nginx-mod-mail openssl dovecot-lua dovecot-pigeonhole-plugin
; rm /etc/nginx/conf.d/stream.conf
COPY conf/ /conf/ COPY conf/ /conf/
COPY --from=static /static/ /static/ COPY --from=static /static/ /static/
COPY *.py / COPY *.py /
COPY dovecot/proxy.conf dovecot/login.lua /dovecot_conf/
RUN echo $VERSION >/version RUN echo $VERSION >/version
EXPOSE 80/tcp 443/tcp 110/tcp 143/tcp 465/tcp 587/tcp 993/tcp 995/tcp 25/tcp EXPOSE 80/tcp 443/tcp 110/tcp 143/tcp 465/tcp 587/tcp 993/tcp 995/tcp 25/tcp 4190/tcp
# EXPOSE 10025/tcp 10143/tcp 14190/tcp # EXPOSE 10025/tcp 10143/tcp 14190/tcp
HEALTHCHECK --start-period=60s CMD curl -skfLo /dev/null http://127.0.0.1:10204/health HEALTHCHECK --start-period=60s CMD curl -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 `cat /run/dovecot/master.pid`
VOLUME ["/certs", "/overrides"] VOLUME ["/certs", "/overrides"]

View File

@@ -27,6 +27,9 @@ class ChangeHandler(FileSystemEventHandler):
if exists("/var/run/nginx.pid"): if exists("/var/run/nginx.pid"):
print("Reloading a running nginx") print("Reloading a running nginx")
system("nginx -s reload") system("nginx -s reload")
if os.path.exists("/run/dovecot/master.pid"):
print("Reloading a running dovecot")
os.system("doveadm reload")
@staticmethod @staticmethod
def reexec_config(): def reexec_config():

View File

@@ -5,7 +5,6 @@ pcre_jit on;
error_log /dev/stderr notice; error_log /dev/stderr notice;
pid /var/run/nginx.pid; pid /var/run/nginx.pid;
load_module "modules/ngx_mail_module.so"; load_module "modules/ngx_mail_module.so";
load_module "modules/ngx_stream_module.so";
events { events {
worker_connections 1024; worker_connections 1024;
@@ -74,6 +73,9 @@ http {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
location /health {
return 204;
}
} }
{% endif %} {% endif %}
@@ -273,7 +275,6 @@ http {
proxy_pass_request_body off; proxy_pass_request_body off;
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
} }
location /health { location /health {
return 204; return 204;
} }
@@ -302,25 +303,6 @@ http {
include /etc/nginx/conf.d/*.conf; include /etc/nginx/conf.d/*.conf;
} }
stream {
log_format main '$remote_addr [$time_local] '
'$protocol $status $bytes_sent $bytes_received '
'$session_time "$upstream_addr" '
'"$upstream_bytes_sent" "$upstream_bytes_received" "$upstream_connect_time"';
access_log /dev/stdout main;
# managesieve
server {
listen 14190;
resolver {{ RESOLVER }} valid=30s;
proxy_connect_timeout 1s;
proxy_timeout 1m;
proxy_protocol on;
proxy_pass {{ IMAP_ADDRESS }}:4190;
}
}
mail { mail {
server_name {{ HOSTNAMES.split(",")[0] }}; server_name {{ HOSTNAMES.split(",")[0] }};
auth_http http://127.0.0.1:8000/auth/email; auth_http http://127.0.0.1:8000/auth/email;

View File

@@ -55,3 +55,7 @@ conf.jinja("/conf/proxy.conf", args, "/etc/nginx/proxy.conf")
conf.jinja("/conf/nginx.conf", args, "/etc/nginx/nginx.conf") conf.jinja("/conf/nginx.conf", args, "/etc/nginx/nginx.conf")
if os.path.exists("/var/run/nginx.pid"): if os.path.exists("/var/run/nginx.pid"):
os.system("nginx -s reload") os.system("nginx -s reload")
conf.jinja("/dovecot_conf/login.lua", args, "/etc/dovecot/login.lua")
conf.jinja("/dovecot_conf/proxy.conf", args, "/etc/dovecot/proxy.conf")
if os.path.exists("/run/dovecot/master.pid"):
os.system("doveadm reload")

View File

@@ -0,0 +1,41 @@
function script_init()
return 0
end
function script_deinit()
end
local http_client = dovecot.http.client {
timeout = 2000;
max_attempts = 3;
}
function auth_passdb_lookup(req)
local auth_request = http_client:request {
url = "http://{{ ADMIN_ADDRESS }}/internal/auth/email";
}
auth_request:add_header('Auth-Port', req.local_port)
auth_request:add_header('Auth-User', req.user)
auth_request:add_header('Auth-Pass', req.password)
auth_request:add_header('Auth-Protocol', req.service)
auth_request:add_header('Client-IP', req.remote_ip)
auth_request:add_header('Client-Port', req.remote_port)
auth_request:add_header('Auth-SSL', req.secured)
auth_request:add_header('Auth-Method', req.mechanism)
local auth_response = auth_request:submit()
local resp_status = auth_response:status()
if resp_status == 200
then
if auth_response:header('Auth-Status') == 'OK'
then
local server = auth_response:header('Auth-Server')
local port = auth_response:header('Auth-Port')
return dovecot.auth.PASSDB_RESULT_OK, "proxy=y host=" .. server .. " port=" .. port .. " nopassword=Y"
else
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, ""
end
else
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, ""
end
end

View File

@@ -0,0 +1,67 @@
###############
# General
###############
log_path = /dev/stderr
auth_verbose=yes
mail_debug=yes
login_log_format_elements = user=<%u> method=%m rip=%r rport=%b lip=%l lport=%a mpid=%e %c
protocols = sieve
postmaster_address = {{ POSTMASTER }}@{{ DOMAIN }}
hostname = {{ HOSTNAMES.split(",")[0] }}
submission_host = {{ FRONT_ADDRESS }}
default_internal_user = dovecot
default_login_user = mail
default_internal_group = dovecot
haproxy_trusted_networks = {% if REAL_IP_FROM %}{% for from_ip in REAL_IP_FROM.split(',') %}{{ from_ip }} {% endfor %}{% endif %}
###############
# Authentication
###############
auth_username_chars =
auth_mechanisms = plain login
{%- if TLS %}
ssl = required
ssl_cert = <{{ TLS[0] }}
ssl_key = <{{ TLS[1] }}
{%- if TLS_FLAVOR in ['letsencrypt','mail-letsencrypt'] %}
ssl_alt_cert = <{{ TLS[2] }}
ssl_alt_key = <{{ TLS[3] }}
{% endif %}
# intermediate configuration
ssl_min_protocol = TLSv1.2
ssl_cipher_list = ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
ssl_prefer_server_ciphers = no
ssl_dh = </conf/dhparam.pem
ssl_options = no_compression no_ticket
{% else %}
disable_plaintext_auth = no
protocol sieve {
ssl = no
}
{% endif %}
passdb {
driver = lua
args = file=/etc/dovecot/login.lua blocking=yes
}
service auth-worker {
user = dovenull
group = dovenull
}
service managesieve-login {
executable = managesieve-login
inet_listener sieve {
port = 4190
{%- if PROXY_PROTOCOL in ['all', 'mail'] %}
haproxy = yes
{% endif %}
}
inet_listener sieve-webmail {
port = 14190
}
}

View File

@@ -16,4 +16,5 @@ elif os.environ["TLS_FLAVOR"] in [ "mail", "cert" ]:
subprocess.Popen(["/certwatcher.py"]) subprocess.Popen(["/certwatcher.py"])
subprocess.call(["/config.py"]) subprocess.call(["/config.py"])
os.system("dovecot -c /etc/dovecot/proxy.conf")
os.execv("/usr/sbin/nginx", ["nginx", "-g", "daemon off;"]) os.execv("/usr/sbin/nginx", ["nginx", "-g", "daemon off;"])

View File

@@ -63,7 +63,7 @@ address for your mail server and that you have a dedicated hostname
with forward and reverse DNS entries for this IP address. with forward and reverse DNS entries for this IP address.
Also, your host must not listen on ports ``25``, ``80``, ``110``, ``143``, Also, your host must not listen on ports ``25``, ``80``, ``110``, ``143``,
``443``, ``465``, ``587``, ``993`` or ``995`` as these are used by Mailu ``443``, ``465``, ``587``, ``993``, ``995`` nor ``4190`` as these are used by Mailu
services. Therefore, you should disable or uninstall any program that is services. Therefore, you should disable or uninstall any program that is
listening on these ports (or have them listen on a different port). For listening on these ports (or have them listen on a different port). For
instance, on a default Debian install: instance, on a default Debian install:

View File

@@ -26,7 +26,7 @@ Main features include:
- **Standard email server**, IMAP and IMAP+, SMTP and Submission with autoconfiguration profiles for clients - **Standard email server**, IMAP and IMAP+, SMTP and Submission with autoconfiguration profiles for clients
- **Advanced email features**, aliases, domain aliases, custom routing - **Advanced email features**, aliases, domain aliases, custom routing
- **Web access**, multiple Webmails and administration interface - **Web access**, multiple Webmails and administration interface
- **User features**, aliases, auto-reply, auto-forward, fetched accounts - **User features**, aliases, auto-reply, auto-forward, fetched accounts, managesieve
- **Admin features**, global admins, announcements, per-domain delegation, quotas - **Admin features**, global admins, announcements, per-domain delegation, quotas
- **Security**, enforced TLS, DANE, MTA-STS, Letsencrypt!, outgoing DKIM, anti-virus scanner, `Snuffleupagus <https://github.com/jvoisin/snuffleupagus/>`_, block malicious attachments - **Security**, enforced TLS, DANE, MTA-STS, Letsencrypt!, outgoing DKIM, anti-virus scanner, `Snuffleupagus <https://github.com/jvoisin/snuffleupagus/>`_, block malicious attachments
- **Antispam**, auto-learn, greylisting, DMARC and SPF, anti-spoofing - **Antispam**, auto-learn, greylisting, DMARC and SPF, anti-spoofing

View File

@@ -30,7 +30,7 @@ services:
options: options:
tag: mailu-front tag: mailu-front
ports: ports:
{% for port in (80, 443, 25, 465, 587, 110, 995, 143, 993) %} {% for port in (80, 443, 25, 465, 587, 110, 995, 143, 993, 4190) %}
{% if bind4 %} {% if bind4 %}
- "{{ bind4 }}:{{ port }}:{{ port }}" - "{{ bind4 }}:{{ port }}:{{ port }}"
{% endif %} {% endif %}

View File

@@ -3,7 +3,8 @@
import imaplib import imaplib
import poplib import poplib
import smtplib import smtplib
import os import sys
import managesieve
SERVER='localhost' SERVER='localhost'
USERNAME='user@mailu.io' USERNAME='user@mailu.io'
@@ -26,7 +27,7 @@ def test_imap(server, username, password):
with imaplib.IMAP4(server) as conn: with imaplib.IMAP4(server) as conn:
conn.login(username, password) conn.login(username, password)
print(f'Authenticating to imap://{username}:{password}@{server}:143/ worked without STARTTLS!') print(f'Authenticating to imap://{username}:{password}@{server}:143/ worked without STARTTLS!')
os.exit(102) sys.exit(102)
except imaplib.IMAP4.error: except imaplib.IMAP4.error:
print('NOK - expected') print('NOK - expected')
@@ -54,7 +55,7 @@ def test_pop3(server, username, password):
conn.pass_(password) conn.pass_(password)
conn.close() conn.close()
print(f'Authenticating to pop3://{username}:{password}@{server}:110/ worked without STARTTLS!') print(f'Authenticating to pop3://{username}:{password}@{server}:110/ worked without STARTTLS!')
os.exit(103) sys.exit(103)
except poplib.error_proto: except poplib.error_proto:
print('NOK - expected') print('NOK - expected')
@@ -77,7 +78,7 @@ def test_SMTP(server, username, password):
conn.ehlo() conn.ehlo()
conn.login(username, password) conn.login(username, password)
print(f'Authenticating to smtp://{username}:{password}@{server}:587/ worked!') print(f'Authenticating to smtp://{username}:{password}@{server}:587/ worked!')
os.exit(104) sys.exit(104)
except smtplib.SMTPNotSupportedError: except smtplib.SMTPNotSupportedError:
print('NOK - expected') print('NOK - expected')
#port 25 should fail #port 25 should fail
@@ -89,7 +90,7 @@ def test_SMTP(server, username, password):
conn.ehlo() conn.ehlo()
conn.login(username, password) conn.login(username, password)
print(f'Authenticating to smtps://{username}:{password}@{server}:25/ worked!') print(f'Authenticating to smtps://{username}:{password}@{server}:25/ worked!')
os.exit(105) sys.exit(105)
except smtplib.SMTPNotSupportedError: except smtplib.SMTPNotSupportedError:
print('NOK - expected') print('NOK - expected')
try: try:
@@ -98,11 +99,36 @@ def test_SMTP(server, username, password):
conn.ehlo() conn.ehlo()
conn.login(username, password) conn.login(username, password)
print(f'Authenticating to smtp://{username}:{password}@{server}:25/ worked without STARTTLS!') print(f'Authenticating to smtp://{username}:{password}@{server}:25/ worked without STARTTLS!')
os.exit(106) sys.exit(106)
except smtplib.SMTPNotSupportedError: except smtplib.SMTPNotSupportedError:
print('NOK - expected') print('NOK - expected')
def test_managesieve(server, username, password):
print(f'Authenticating to sieve://{username}:{password}@{server}:4190/')
m=managesieve.MANAGESIEVE(server)
try:
m.login('PLAIN', username, password)
print(f'Worked without STARTTLS!')
sys.exit(107)
except managesieve.MANAGESIEVE.abort:
pass
m=managesieve.MANAGESIEVE(server, use_tls=True)
if m.login('', username, 'wrongpass') != 'NO':
print(f'Authenticating to sieve://{username}:{password}@{server}:4190/ with wrong creds has worked!')
sys.exit(108)
if m.login('', username, password) != 'OK':
print(f'Authenticating to sieve://{username}:{password}@{server}:4190/ has failed!')
sys.exit(109)
if m.listscripts()[0] != 'OK':
print(f'Listing scripts failed!')
sys.exit(110)
print('OK')
if __name__ == '__main__': if __name__ == '__main__':
test_imap(SERVER, USERNAME, PASSWORD) test_imap(SERVER, USERNAME, PASSWORD)
test_pop3(SERVER, USERNAME, PASSWORD) test_pop3(SERVER, USERNAME, PASSWORD)
test_SMTP(SERVER, USERNAME, PASSWORD) test_SMTP(SERVER, USERNAME, PASSWORD)
test_managesieve(SERVER, USERNAME, PASSWORD)

View File

@@ -30,6 +30,7 @@ services:
- "127.0.0.1:995:995" - "127.0.0.1:995:995"
- "127.0.0.1:143:143" - "127.0.0.1:143:143"
- "127.0.0.1:993:993" - "127.0.0.1:993:993"
- "127.0.0.1:4190:4190"
volumes: volumes:
- "/mailu/certs:/certs" - "/mailu/certs:/certs"

View File

@@ -1,2 +1,3 @@
docker==4.2.2 docker==4.2.2
colorama==0.4.3 colorama==0.4.3
managesieve==0.7.1

View File

@@ -0,0 +1 @@
Add support for managesieve

View File

@@ -25,7 +25,14 @@ $config['smtp_user'] = '%u';
$config['smtp_pass'] = '%p'; $config['smtp_pass'] = '%p';
// Sieve script management // Sieve script management
$config['managesieve_host'] = '{{ FRONT_ADDRESS or "front" }}:14190'; $config['managesieve_host'] = 'tls://{{ FRONT_ADDRESS or "front" }}:14190';
$config['managesieve_conn_options'] = array(
'ssl' => array(
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
),
);
$config['managesieve_mbox_encoding'] = 'UTF8'; $config['managesieve_mbox_encoding'] = 'UTF8';
// roundcube customization // roundcube customization

View File

@@ -34,12 +34,12 @@
"Sieve": { "Sieve": {
"host": "{{ FRONT_ADDRESS }}", "host": "{{ FRONT_ADDRESS }}",
"port": 14190, "port": 14190,
"secure": 0, "type": 2,
"shortLogin": false, "shortLogin": false,
"ssl": { "ssl": {
"verify_peer": false, "verify_peer": false,
"verify_peer_name": false, "verify_peer_name": false,
"allow_self_signed": false, "allow_self_signed": true,
"SNI_enabled": true, "SNI_enabled": true,
"disable_compression": true, "disable_compression": true,
"security_level": 1 "security_level": 1