diff --git a/CHANGELOG.md b/CHANGELOG.md index a09110cf..babb04ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ Mail: * Roundcube is updated to version 1.2.0. * SSLv3 and RC4 are now no longer supported in incoming and outgoing mail (SMTP port 25). +Control panel: + +* The users and aliases APIs are now documented on their control panel pages. +* The HSTS header was missing. +* New status checks were added for the ufw firewall. + +System: + +* fail2ban jails added for SMTP submission, Roundcube, ownCloud, the control panel, and munin. +* Mail-in-a-Box can now be installed on the i686 architecture. + v0.18c (June 2, 2016) --------------------- diff --git a/conf/fail2ban/dovecotimap.conf b/conf/fail2ban/filter.d/dovecotimap.conf similarity index 100% rename from conf/fail2ban/dovecotimap.conf rename to conf/fail2ban/filter.d/dovecotimap.conf diff --git a/conf/fail2ban/filter.d/miab-management-daemon.conf b/conf/fail2ban/filter.d/miab-management-daemon.conf new file mode 100644 index 00000000..0b0489c2 --- /dev/null +++ b/conf/fail2ban/filter.d/miab-management-daemon.conf @@ -0,0 +1,12 @@ +# Fail2Ban filter Mail-in-a-Box management daemon + +[INCLUDES] + +before = common.conf + +[Definition] + +_daemon = mailinabox + +failregex = Mail-in-a-Box Management Daemon: Failed login attempt from ip - timestamp .* +ignoreregex = diff --git a/conf/fail2ban/filter.d/miab-munin.conf b/conf/fail2ban/filter.d/miab-munin.conf new file mode 100644 index 00000000..b254cc62 --- /dev/null +++ b/conf/fail2ban/filter.d/miab-munin.conf @@ -0,0 +1,7 @@ +[INCLUDES] + +before = common.conf + +[Definition] +failregex= - .*GET /admin/munin/.* HTTP/1.1\" 401.* +ignoreregex = diff --git a/conf/fail2ban/filter.d/miab-owncloud.conf b/conf/fail2ban/filter.d/miab-owncloud.conf new file mode 100644 index 00000000..a9a13f2c --- /dev/null +++ b/conf/fail2ban/filter.d/miab-owncloud.conf @@ -0,0 +1,7 @@ +[INCLUDES] + +before = common.conf + +[Definition] +failregex=Login failed: .*Remote IP: '[\)'] +ignoreregex = diff --git a/conf/fail2ban/filter.d/miab-postfix-submission.conf b/conf/fail2ban/filter.d/miab-postfix-submission.conf new file mode 100644 index 00000000..236e1331 --- /dev/null +++ b/conf/fail2ban/filter.d/miab-postfix-submission.conf @@ -0,0 +1,7 @@ +[INCLUDES] + +before = common.conf + +[Definition] +failregex=postfix/submission/smtpd.*warning.*\[\]: .* authentication (failed|aborted) +ignoreregex = diff --git a/conf/fail2ban/filter.d/miab-roundcube.conf b/conf/fail2ban/filter.d/miab-roundcube.conf new file mode 100644 index 00000000..c6979c85 --- /dev/null +++ b/conf/fail2ban/filter.d/miab-roundcube.conf @@ -0,0 +1,9 @@ +[INCLUDES] + +before = common.conf + +[Definition] + +failregex = IMAP Error: Login failed for .*? from \. AUTHENTICATE.* + +ignoreregex = diff --git a/conf/fail2ban/jail.local b/conf/fail2ban/jails.conf similarity index 58% rename from conf/fail2ban/jail.local rename to conf/fail2ban/jails.conf index dc338803..0146b64c 100644 --- a/conf/fail2ban/jail.local +++ b/conf/fail2ban/jails.conf @@ -1,4 +1,5 @@ -# Fail2Ban configuration file for Mail-in-a-Box +# Fail2Ban configuration file for Mail-in-a-Box. Do not edit. +# This file is re-generated on updates. [DEFAULT] # Whitelist our own IP addresses. 127.0.0.1/8 is the default. But our status checks @@ -6,24 +7,52 @@ # ours too. The string is substituted during installation. ignoreip = 127.0.0.1/8 PUBLIC_IP -# JAILS - -[ssh] -maxretry = 7 -bantime = 3600 - -[ssh-ddos] -enabled = true - -[sasl] -enabled = true - [dovecot] enabled = true filter = dovecotimap +logpath = /var/log/mail.log findtime = 30 maxretry = 20 + +[miab-management] +enabled = true +filter = miab-management-daemon +port = http,https +logpath = /var/log/syslog +maxretry = 20 +findtime = 30 + +[miab-munin] +enabled = true +port = http,https +filter = miab-munin +logpath = /var/log/nginx/access.log +maxretry = 20 +findtime = 30 + +[miab-owncloud] +enabled = true +port = http,https +filter = miab-owncloud +logpath = STORAGE_ROOT/owncloud/owncloud.log +maxretry = 20 +findtime = 120 + +[miab-postfix587] +enabled = true +port = 587 +filter = miab-postfix-submission logpath = /var/log/mail.log +maxretry = 20 +findtime = 30 + +[miab-roundcube] +enabled = true +port = http,https +filter = miab-roundcube +logpath = /var/log/roundcubemail/errors +maxretry = 20 +findtime = 30 [recidive] enabled = true @@ -39,3 +68,13 @@ action = iptables-allports[name=recidive] # By default we don't configure this address and no action is required from the admin anyway. # So the notification is ommited. This will prevent message appearing in the mail.log that mail # can't be delivered to fail2ban@$HOSTNAME. + +[sasl] +enabled = true + +[ssh] +maxretry = 7 +bantime = 3600 + +[ssh-ddos] +enabled = true diff --git a/conf/nginx-primaryonly.conf b/conf/nginx-primaryonly.conf index 55c80eba..eb446251 100644 --- a/conf/nginx-primaryonly.conf +++ b/conf/nginx-primaryonly.conf @@ -9,6 +9,7 @@ add_header X-Frame-Options "DENY"; add_header X-Content-Type-Options nosniff; add_header Content-Security-Policy "frame-ancestors 'none';"; + add_header Strict-Transport-Security max-age=31536000; } # ownCloud configuration. diff --git a/management/daemon.py b/management/daemon.py index 5400925f..9bc6429b 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -1,7 +1,8 @@ #!/usr/bin/python3 -import os, os.path, re, json +import os, os.path, re, json, time import subprocess + from functools import wraps from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response @@ -45,6 +46,9 @@ def authorized_personnel_only(viewfunc): privs = [] error = "Incorrect username or password" + # Write a line in the log recording the failed login + log_failed_login(request) + # Authorized to access an API view? if "admin" in privs: # Call view func. @@ -117,6 +121,9 @@ def me(): try: email, privs = auth_service.authenticate(request, env) except ValueError as e: + # Log the failed login + log_failed_login(request) + return json_response({ "status": "invalid", "reason": "Incorrect username or password", @@ -583,6 +590,22 @@ def munin_cgi(filename): app.logger.warning("munin_cgi: munin-cgi-graph returned 404 status code. PATH_INFO=%s", env['PATH_INFO']) return response +def log_failed_login(request): + # We need to figure out the ip to list in the message, all our calls are routed + # through nginx who will put the original ip in X-Forwarded-For. + # During setup we call the management interface directly to determine the user + # status. So we can't always use X-Forwarded-For because during setup that header + # will not be present. + if request.headers.getlist("X-Forwarded-For"): + ip = request.headers.getlist("X-Forwarded-For")[0] + else: + ip = request.remote_addr + + # We need to add a timestamp to the log message, otherwise /dev/log will eat the "duplicate" + # message. + app.logger.warning( "Mail-in-a-Box Management Daemon: Failed login attempt from ip %s - timestamp %s" % (ip, time.time())) + + # APP if __name__ == '__main__': diff --git a/management/status_checks.py b/management/status_checks.py index 36a87ea1..13cbab12 100755 --- a/management/status_checks.py +++ b/management/status_checks.py @@ -18,6 +18,29 @@ from mailconfig import get_mail_domains, get_mail_aliases from utils import shell, sort_domains, load_env_vars_from_file, load_settings +def get_services(): + return [ + { "name": "Local DNS (bind9)", "port": 53, "public": False, }, + #{ "name": "NSD Control", "port": 8952, "public": False, }, + { "name": "Local DNS Control (bind9/rndc)", "port": 953, "public": False, }, + { "name": "Dovecot LMTP LDA", "port": 10026, "public": False, }, + { "name": "Postgrey", "port": 10023, "public": False, }, + { "name": "Spamassassin", "port": 10025, "public": False, }, + { "name": "OpenDKIM", "port": 8891, "public": False, }, + { "name": "OpenDMARC", "port": 8893, "public": False, }, + { "name": "Memcached", "port": 11211, "public": False, }, + { "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, }, + { "name": "SSH Login (ssh)", "port": get_ssh_port(), "public": True, }, + { "name": "Public DNS (nsd4)", "port": 53, "public": True, }, + { "name": "Incoming Mail (SMTP/postfix)", "port": 25, "public": True, }, + { "name": "Outgoing Mail (SMTP 587/postfix)", "port": 587, "public": True, }, + #{ "name": "Postfix/master", "port": 10587, "public": True, }, + { "name": "IMAPS (dovecot)", "port": 993, "public": True, }, + { "name": "Mail Filters (Sieve/dovecot)", "port": 4190, "public": True, }, + { "name": "HTTP Web (nginx)", "port": 80, "public": True, }, + { "name": "HTTPS Web (nginx)", "port": 443, "public": True, }, + ] + def run_checks(rounded_values, env, output, pool): # run systems checks output.add_heading("System") @@ -61,33 +84,9 @@ def get_ssh_port(): def run_services_checks(env, output, pool): # Check that system services are running. - - services = [ - { "name": "Local DNS (bind9)", "port": 53, "public": False, }, - #{ "name": "NSD Control", "port": 8952, "public": False, }, - { "name": "Local DNS Control (bind9/rndc)", "port": 953, "public": False, }, - { "name": "Dovecot LMTP LDA", "port": 10026, "public": False, }, - { "name": "Postgrey", "port": 10023, "public": False, }, - { "name": "Spamassassin", "port": 10025, "public": False, }, - { "name": "OpenDKIM", "port": 8891, "public": False, }, - { "name": "OpenDMARC", "port": 8893, "public": False, }, - { "name": "Memcached", "port": 11211, "public": False, }, - { "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, }, - - { "name": "SSH Login (ssh)", "port": get_ssh_port(), "public": True, }, - { "name": "Public DNS (nsd4)", "port": 53, "public": True, }, - { "name": "Incoming Mail (SMTP/postfix)", "port": 25, "public": True, }, - { "name": "Outgoing Mail (SMTP 587/postfix)", "port": 587, "public": True, }, - #{ "name": "Postfix/master", "port": 10587, "public": True, }, - { "name": "IMAPS (dovecot)", "port": 993, "public": True, }, - { "name": "Mail Filters (Sieve/dovecot)", "port": 4190, "public": True, }, - { "name": "HTTP Web (nginx)", "port": 80, "public": True, }, - { "name": "HTTPS Web (nginx)", "port": 443, "public": True, }, - ] - all_running = True fatal = False - ret = pool.starmap(check_service, ((i, service, env) for i, service in enumerate(services)), chunksize=1) + ret = pool.starmap(check_service, ((i, service, env) for i, service in enumerate(get_services())), chunksize=1) for i, running, fatal2, output2 in sorted(ret): if output2 is None: continue # skip check (e.g. no port was set, e.g. no sshd) all_running = all_running and running @@ -169,6 +168,26 @@ def run_system_checks(rounded_values, env, output): check_free_disk_space(rounded_values, env, output) check_free_memory(rounded_values, env, output) +def check_ufw(env, output): + ufw = shell('check_output', ['ufw', 'status']).splitlines() + + if ufw[0] == "Status: active": + not_allowed_ports = 0 + for service in get_services(): + if service["public"] and not is_port_allowed(ufw, service["port"]): + not_allowed_ports += 1 + output.print_error("Port %s (%s) should be allowed in the firewall, please re-run the setup." % (service["port"], service["name"])) + + if not_allowed_ports == 0: + output.print_ok("Firewall is active.") + else: + output.print_warning("""The firewall is disabled on this machine. This might be because the system + is protected by an external firewall. We can't protect the system against bruteforce attacks + without the local firewall active. Connect to the system via ssh and try to run: ufw enable.""") + +def is_port_allowed(ufw, port): + return any(re.match(str(port) +"[/ \t].*", item) for item in ufw) + def check_ssh_password(env, output): # Check that SSH login with password is disabled. The openssh-server # package may not be installed so check that before trying to access @@ -240,6 +259,8 @@ def run_network_checks(env, output): output.add_heading("Network") + check_ufw(env, output) + # Stop if we cannot make an outbound connection on port 25. Many residential # networks block outbound port 25 to prevent their network from sending spam. # See if we can reach one of Google's MTAs with a 5-second timeout. diff --git a/management/templates/aliases.html b/management/templates/aliases.html index dc916f95..d5a123ff 100644 --- a/management/templates/aliases.html +++ b/management/templates/aliases.html @@ -106,6 +106,40 @@ +

Mail alias API

+ +

Use your box’s Mail alias API to add/remove aliases.

+ +

Usage:

+ +
curl -X VERB [-d "value"] --user {email}:{password} https://{{hostname}}/admin/mail/aliases[action]
+ +

(Brackets denote an optional argument.)

+

(Adding ?format=json will give json encoded results)

+ +

Verbs

+ + + + + + +
Verb Action
GET Returns a list of existing mail aliases.
POST/add Adds a new mail alias. Required parameters are address and forward_to.
POST/remove Removes a mail alias. Required parameter is address.
+ +

Examples:

+ +

Try these examples. For simplicity the examples omit the --user me@mydomain.com:yourpassword command line argument which you must fill in with your email address and password.

+ +
# Gives a json encoded list of all mail users
+curl -X GET https://{{hostname}}/admin/mail/users?format=json
+
+# adds a new email alias
+curl -X POST -d "address=new_alias@mydomail.com" -d "forward_to=my_email@mydomain.com" https://{{hostname}}/admin/mail/aliases/add
+
+# removes a email alias
+curl -X POST -d "address=new_alias@mydomail.com" https://{{hostname}}/admin/mail/aliases/remove
+
+