1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2025-04-21 03:02:09 +00:00

Merge branch 'master' into owncloud9

This commit is contained in:
yodax 2016-07-31 14:58:58 +02:00
commit 294f13c630
17 changed files with 462 additions and 48 deletions

View File

@ -9,6 +9,17 @@ Mail:
* Roundcube is updated to version 1.2.0. * Roundcube is updated to version 1.2.0.
* SSLv3 and RC4 are now no longer supported in incoming and outgoing mail (SMTP port 25). * 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) v0.18c (June 2, 2016)
--------------------- ---------------------

View File

@ -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 <HOST> - timestamp .*
ignoreregex =

View File

@ -0,0 +1,7 @@
[INCLUDES]
before = common.conf
[Definition]
failregex=<HOST> - .*GET /admin/munin/.* HTTP/1.1\" 401.*
ignoreregex =

View File

@ -0,0 +1,7 @@
[INCLUDES]
before = common.conf
[Definition]
failregex=Login failed: .*Remote IP: '<HOST>[\)']
ignoreregex =

View File

@ -0,0 +1,7 @@
[INCLUDES]
before = common.conf
[Definition]
failregex=postfix/submission/smtpd.*warning.*\[<HOST>\]: .* authentication (failed|aborted)
ignoreregex =

View File

@ -0,0 +1,9 @@
[INCLUDES]
before = common.conf
[Definition]
failregex = IMAP Error: Login failed for .*? from <HOST>\. AUTHENTICATE.*
ignoreregex =

View File

@ -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] [DEFAULT]
# Whitelist our own IP addresses. 127.0.0.1/8 is the default. But our status checks # 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. # ours too. The string is substituted during installation.
ignoreip = 127.0.0.1/8 PUBLIC_IP ignoreip = 127.0.0.1/8 PUBLIC_IP
# JAILS
[ssh]
maxretry = 7
bantime = 3600
[ssh-ddos]
enabled = true
[sasl]
enabled = true
[dovecot] [dovecot]
enabled = true enabled = true
filter = dovecotimap filter = dovecotimap
logpath = /var/log/mail.log
findtime = 30 findtime = 30
maxretry = 20 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 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] [recidive]
enabled = true 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. # 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 # So the notification is ommited. This will prevent message appearing in the mail.log that mail
# can't be delivered to fail2ban@$HOSTNAME. # can't be delivered to fail2ban@$HOSTNAME.
[sasl]
enabled = true
[ssh]
maxretry = 7
bantime = 3600
[ssh-ddos]
enabled = true

View File

@ -9,6 +9,7 @@
add_header X-Frame-Options "DENY"; add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options nosniff; add_header X-Content-Type-Options nosniff;
add_header Content-Security-Policy "frame-ancestors 'none';"; add_header Content-Security-Policy "frame-ancestors 'none';";
add_header Strict-Transport-Security max-age=31536000;
} }
# ownCloud configuration. # ownCloud configuration.

View File

@ -1,7 +1,8 @@
#!/usr/bin/python3 #!/usr/bin/python3
import os, os.path, re, json import os, os.path, re, json, time
import subprocess import subprocess
from functools import wraps from functools import wraps
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
@ -45,6 +46,9 @@ def authorized_personnel_only(viewfunc):
privs = [] privs = []
error = "Incorrect username or password" 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? # Authorized to access an API view?
if "admin" in privs: if "admin" in privs:
# Call view func. # Call view func.
@ -117,6 +121,9 @@ def me():
try: try:
email, privs = auth_service.authenticate(request, env) email, privs = auth_service.authenticate(request, env)
except ValueError as e: except ValueError as e:
# Log the failed login
log_failed_login(request)
return json_response({ return json_response({
"status": "invalid", "status": "invalid",
"reason": "Incorrect username or password", "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']) app.logger.warning("munin_cgi: munin-cgi-graph returned 404 status code. PATH_INFO=%s", env['PATH_INFO'])
return response 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 # APP
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -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 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): def run_checks(rounded_values, env, output, pool):
# run systems checks # run systems checks
output.add_heading("System") output.add_heading("System")
@ -61,33 +84,9 @@ def get_ssh_port():
def run_services_checks(env, output, pool): def run_services_checks(env, output, pool):
# Check that system services are running. # 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 all_running = True
fatal = False 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): 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) if output2 is None: continue # skip check (e.g. no port was set, e.g. no sshd)
all_running = all_running and running 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_disk_space(rounded_values, env, output)
check_free_memory(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): def check_ssh_password(env, output):
# Check that SSH login with password is disabled. The openssh-server # Check that SSH login with password is disabled. The openssh-server
# package may not be installed so check that before trying to access # 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") output.add_heading("Network")
check_ufw(env, output)
# Stop if we cannot make an outbound connection on port 25. Many residential # 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. # 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. # See if we can reach one of Google's MTAs with a 5-second timeout.

View File

@ -106,6 +106,40 @@
</table> </table>
</div> </div>
<h3>Mail alias API</h3>
<p>Use your box&rsquo;s Mail alias API to add/remove aliases.</p>
<p>Usage:</p>
<pre>curl -X <b>VERB</b> [-d "<b>value</b>"] --user {email}:{password} https://{{hostname}}/admin/mail/aliases[<b>action</b>]</pre>
<p>(Brackets denote an optional argument.)</p>
<p>(Adding <code>?format=json</code> will give json encoded results)</p>
<h4>Verbs</h4>
<table class="table">
<thead><th>Verb</th> <th>Action</th><th></th></thead>
<tr><td>GET</td><td></td> <td>Returns a list of existing mail aliases.</td></tr>
<tr><td>POST</td><td>/add</td> <td>Adds a new mail alias. Required parameters are <code>address</code> and <code>forward_to</code>.</td></tr>
<tr><td>POST</td><td>/remove</td> <td>Removes a mail alias. Required parameter is <code>address</code>.</td></tr>
</table>
<h4>Examples:</h4>
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your email address and password.</p>
<pre># 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
</pre>
<script> <script>
function show_aliases() { function show_aliases() {

View File

@ -84,6 +84,47 @@
</table> </table>
</div> </div>
<h3>Mail user API</h3>
<p>Use your box&rsquo;s Mail user API to add/change/remove users.</p>
<p>Usage:</p>
<pre>curl -X <b>VERB</b> [-d "<b>value</b>"] --user {email}:{password} https://{{hostname}}/admin/mail/users[<b>action</b>]</pre>
<p>(Brackets denote an optional argument.)</p>
<p>(Adding <code>?format=json</code> will give json encoded results)</p>
<h4>Verbs</h4>
<table class="table">
<thead><th>Verb</th> <th>Action</th><th></th></thead>
<tr><td>GET</td><td></td> <td>Returns a list of existing mail users.</td></tr>
<tr><td>POST</td><td>/add</td> <td>Adds a new mail user. Required parameters are <code>email</code> and <code>password</code>.</td></tr>
<tr><td>POST</td><td>/remove</td> <td>Removes a mail user. Required parameter is <code>email</code>.</td></tr>
<tr><td>POST</td><td>/privileges/add</td> <td>Used to make a mail user an admin. Required parameters are <code>email</code> and <code>privilege=admin</code>.</td></tr>
<tr><td>POST</td><td>/privileges/remove</td> <td>Used to remove the admin privilege from a mail user. Required parameter is <code>email</code>.</td></tr>
</table>
<h4>Examples:</h4>
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your email address and password.</p>
<pre># Gives a json encoded list of all mail users
curl -X GET https://{{hostname}}/admin/mail/users?format=json
# adds a new email user
curl -X POST -d "email=new_user@mydomail.com" -d "password=s3curE_pa5Sw0rD" https://{{hostname}}/admin/mail/users/add
# removes a email user
curl -X POST -d "email=new_user@mydomail.com" https://{{hostname}}/admin/mail/users/remove
# adds admin privilege to an email user
curl -X POST -d "email=new_user@mydomail.com" -d "privilege=admin" https://{{hostname}}/admin/mail/users/privileges/add
# removes admin privilege from an email user
curl -X POST -d "email=new_user@mydomail.com" https://{{hostname}}/admin/mail/users/privileges/remove
</pre>
<script> <script>
function show_users() { function show_users() {

View File

@ -153,7 +153,6 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
mkdir -p $STORAGE_ROOT/owncloud mkdir -p $STORAGE_ROOT/owncloud
# Create an initial configuration file. # Create an initial configuration file.
TIMEZONE=$(cat /etc/timezone)
instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1) instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1)
cat > $STORAGE_ROOT/owncloud/config.php <<EOF; cat > $STORAGE_ROOT/owncloud/config.php <<EOF;
<?php <?php
@ -183,7 +182,6 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
'mail_smtppassword' => '', 'mail_smtppassword' => '',
'mail_from_address' => 'owncloud', 'mail_from_address' => 'owncloud',
'mail_domain' => '$PRIMARY_HOSTNAME', 'mail_domain' => '$PRIMARY_HOSTNAME',
'logtimezone' => '$TIMEZONE',
); );
?> ?>
EOF EOF
@ -221,7 +219,11 @@ fi
# so set it here. It also can change if the box's PRIMARY_HOSTNAME changes, so # so set it here. It also can change if the box's PRIMARY_HOSTNAME changes, so
# this will make sure it has the right value. # this will make sure it has the right value.
# * Some settings weren't included in previous versions of Mail-in-a-Box. # * Some settings weren't included in previous versions of Mail-in-a-Box.
# * We need to set the timezone to the system timezone to allow fail2ban to ban
# users within the proper timeframe
# * We need to set the logdateformat to something that will work correctly with fail2ban
# Use PHP to read the settings file, modify it, and write out the new settings array. # Use PHP to read the settings file, modify it, and write out the new settings array.
TIMEZONE=$(cat /etc/timezone)
CONFIG_TEMP=$(/bin/mktemp) CONFIG_TEMP=$(/bin/mktemp)
php <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php; php <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php;
<?php <?php
@ -233,6 +235,9 @@ include("$STORAGE_ROOT/owncloud/config.php");
\$CONFIG['overwrite.cli.url'] = '/cloud'; \$CONFIG['overwrite.cli.url'] = '/cloud';
\$CONFIG['mail_from_address'] = 'administrator'; # just the local part, matches our master administrator address \$CONFIG['mail_from_address'] = 'administrator'; # just the local part, matches our master administrator address
\$CONFIG['logtimezone'] = '$TIMEZONE';
\$CONFIG['logdateformat'] = 'Y-m-d H:i:s';
echo "<?php\n\\\$CONFIG = "; echo "<?php\n\\\$CONFIG = ";
var_export(\$CONFIG); var_export(\$CONFIG);
echo ";"; echo ";";

View File

@ -47,15 +47,15 @@ if [ -e ~/.wgetrc ]; then
exit exit
fi fi
# Check that we are running on x86_64, any other architecture is unsupported and # Check that we are running on x86_64 or i686, any other architecture is unsupported and
# will fail later in the setup when we try to install the custom build lucene packages. # will fail later in the setup when we try to install the custom build lucene packages.
# #
# Set ARM=1 to ignore this check if you have built the packages yourself. If you do this # Set ARM=1 to ignore this check if you have built the packages yourself. If you do this
# you are on your own! # you are on your own!
ARCHITECTURE=$(uname -m) ARCHITECTURE=$(uname -m)
if [ "$ARCHITECTURE" != "x86_64" ]; then if [ "$ARCHITECTURE" != "x86_64" ] && [ "$ARCHITECTURE" != "i686" ]; then
if [ -z "$ARM" ]; then if [ -z "$ARM" ]; then
echo "Mail-in-a-Box only supports x86_64 and will not work on any other architecture, like ARM." echo "Mail-in-a-Box only supports x86_64 or i686 and will not work on any other architecture, like ARM."
echo "Your architecture is $ARCHITECTURE" echo "Your architecture is $ARCHITECTURE"
exit exit
fi fi

View File

@ -291,10 +291,12 @@ restart_service resolvconf
# ### Fail2Ban Service # ### Fail2Ban Service
# Configure the Fail2Ban installation to prevent dumb bruce-force attacks against dovecot, postfix and ssh # Configure the Fail2Ban installation to prevent dumb bruce-force attacks against dovecot, postfix, ssh, etc.
cat conf/fail2ban/jail.local \ rm -f /etc/fail2ban/jail.local # we used to use this file but don't anymore
cat conf/fail2ban/jails.conf \
| sed "s/PUBLIC_IP/$PUBLIC_IP/g" \ | sed "s/PUBLIC_IP/$PUBLIC_IP/g" \
> /etc/fail2ban/jail.local | sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
cp conf/fail2ban/dovecotimap.conf /etc/fail2ban/filter.d/dovecotimap.conf > /etc/fail2ban/jail.d/mailinabox.conf
cp -f conf/fail2ban/filter.d/* /etc/fail2ban/filter.d/
restart_service fail2ban restart_service fail2ban

195
tests/fail2ban.py Normal file
View File

@ -0,0 +1,195 @@
# Test that a box's fail2ban setting are working
# correctly by attempting a bunch of failed logins.
# Specify SSH login information the command line -
# we use that to reset fail2ban after each test,
# and we extract the hostname from that to open
# connections to.
######################################################################
import sys, os, time, functools
# parse command line
if len(sys.argv) < 2:
print("Usage: tests/fail2ban.py user@hostname")
sys.exit(1)
ssh_user, hostname = sys.argv[1].split("@", 1)
# define some test types
import socket
socket.setdefaulttimeout(10)
class IsBlocked(Exception):
"""Tests raise this exception when it appears that a fail2ban
jail is in effect, i.e. on a connection refused error."""
pass
def smtp_test():
import smtplib
try:
server = smtplib.SMTP(hostname, 587)
except ConnectionRefusedError:
# looks like fail2ban worked
raise IsBlocked()
server.starttls()
server.ehlo_or_helo_if_needed()
try:
server.login("fakeuser", "fakepassword")
raise Exception("authentication didn't fail")
except smtplib.SMTPAuthenticationError:
# athentication should fail
pass
try:
server.quit()
except:
# ignore errors here
pass
def imap_test():
import imaplib
try:
M = imaplib.IMAP4_SSL(hostname)
except ConnectionRefusedError:
# looks like fail2ban worked
raise IsBlocked()
try:
M.login("fakeuser", "fakepassword")
raise Exception("authentication didn't fail")
except imaplib.IMAP4.error:
# authentication should fail
pass
finally:
M.logout() # shuts down connection, has nothing to do with login()
def http_test(url, expected_status, postdata=None, qsargs=None, auth=None):
import urllib.parse
import requests
from requests.auth import HTTPBasicAuth
# form request
url = urllib.parse.urljoin("https://" + hostname, url)
if qsargs: url += "?" + urllib.parse.urlencode(qsargs)
urlopen = requests.get if not postdata else requests.post
try:
# issue request
r = urlopen(
url,
auth=HTTPBasicAuth(*auth) if auth else None,
data=postdata,
headers={'User-Agent': 'Mail-in-a-Box fail2ban tester'},
timeout=8)
except requests.exceptions.ConnectTimeout as e:
raise IsBlocked()
except requests.exceptions.ConnectionError as e:
if "Connection refused" in str(e):
raise IsBlocked()
raise # some other unexpected condition
# return response status code
if r.status_code != expected_status:
r.raise_for_status() # anything but 200
raise IOError("Got unexpected status code %s." % r.status_code)
# define how to run a test
def restart_fail2ban_service(final=False):
# Log in over SSH to restart fail2ban.
command = "sudo fail2ban-client reload"
if not final:
# Stop recidive jails during testing.
command += " && sudo fail2ban-client stop recidive"
os.system("ssh %s@%s \"%s\"" % (ssh_user, hostname, command))
def testfunc_runner(i, testfunc, *args):
print(i+1, end=" ", flush=True)
testfunc(*args)
def run_test(testfunc, args, count, within_seconds, parallel):
# Run testfunc count times in within_seconds seconds (and actually
# within a little less time so we're sure we're under the limit).
#
# Because some services are slow, like IMAP, we can't necessarily
# run testfunc sequentially and still get to count requests within
# the required time. So we split the requests across threads.
import requests.exceptions
from multiprocessing import Pool
restart_fail2ban_service()
# Log.
print(testfunc.__name__, " ".join(str(a) for a in args), "...")
# Record the start time so we can know how to evenly space our
# calls to testfunc.
start_time = time.time()
with Pool(parallel) as p:
# Distribute the requests across the pool.
asyncresults = []
for i in range(count):
ar = p.apply_async(testfunc_runner, [i, testfunc] + list(args))
asyncresults.append(ar)
# Wait for all runs to finish.
p.close()
p.join()
# Check for errors.
for ar in asyncresults:
try:
ar.get()
except IsBlocked:
print("Test machine prematurely blocked!")
return False
# Did we make enough requests within the limit?
if (time.time()-start_time) > within_seconds:
raise Exception("Test failed to make %s requests in %d seconds." % (count, within_seconds))
# Wait a moment for the block to be put into place.
time.sleep(4)
# The next call should fail.
print("*", end=" ", flush=True)
try:
testfunc(*args)
except IsBlocked:
# Success -- this one is supposed to be refused.
print("blocked [OK]")
return True # OK
print("not blocked!")
return False
######################################################################
if __name__ == "__main__":
# run tests
# SMTP bans at 10 even though we say 20 in the config because we get
# doubled-up warnings in the logs, we'll let that be for now
run_test(smtp_test, [], 10, 30, 8)
# IMAP
run_test(imap_test, [], 20, 30, 4)
# Mail-in-a-Box control panel
run_test(http_test, ["/admin/me", 200], 20, 30, 1)
# Munin via the Mail-in-a-Box control panel
run_test(http_test, ["/admin/munin/", 401], 20, 30, 1)
# ownCloud
run_test(http_test, ["/cloud/remote.php/webdav", 401, None, None, ["aa", "aa"]], 20, 120, 1)
# restart fail2ban so that this client machine is no longer blocked
restart_fail2ban_service(final=True)