From 27dcb5d7cab96a984e1f35d4a21e957b3f448182 Mon Sep 17 00:00:00 2001
From: downtownallday <downtownallday@gmail.com>
Date: Mon, 18 Jul 2022 15:52:04 -0400
Subject: [PATCH] Enable fail2ban for z-push and add a test for it

---
 conf/fail2ban/filter.d/z-push.conf |  15 ++++
 conf/fail2ban/jails.conf           |   7 ++
 setup/zpush.sh                     |   1 +
 tests/suites/_init_miabldap.sh     |   2 +-
 tests/suites/z-push.sh             | 136 ++++++++++++++++++++++++++++-
 tools/editconf.py                  |  41 +++++++--
 6 files changed, 194 insertions(+), 8 deletions(-)
 create mode 100644 conf/fail2ban/filter.d/z-push.conf

diff --git a/conf/fail2ban/filter.d/z-push.conf b/conf/fail2ban/filter.d/z-push.conf
new file mode 100644
index 00000000..190c2e63
--- /dev/null
+++ b/conf/fail2ban/filter.d/z-push.conf
@@ -0,0 +1,15 @@
+# Source: https://kb.kopano.io/display/ZP/Fail2Ban+support
+[INCLUDES]
+before = common.conf
+[Definition]
+# Option:  failregex
+# Notes.:  regex to match the password failures messages in the logfile. The
+#          host must be matched by a group named "host". The tag "<HOST>" can
+#          be used for standard IP/hostname matching and is only an alias for
+#          (?:::f{4,6}:)?(?P<host>[\w\-.^_]+)
+# Values:  TEXT
+#
+failregex = IP: <HOST> failed to authenticate user
+ignoreregex =
+[Init]
+journalmatch = _SYSTEMD_UNIT=fail2ban.service
diff --git a/conf/fail2ban/jails.conf b/conf/fail2ban/jails.conf
index 44fb6022..4556d043 100644
--- a/conf/fail2ban/jails.conf
+++ b/conf/fail2ban/jails.conf
@@ -88,3 +88,10 @@ bantime = 3600
 [slapd]
 enabled = true
 logpath = /var/log/ldap/slapd.log
+
+[z-push]
+enabled = true
+port     = http,https
+filter   = z-push
+logpath  = /var/log/z-push/z-push-error.log
+maxretry = 20
diff --git a/setup/zpush.sh b/setup/zpush.sh
index 4fdfadc4..ecbbffc6 100755
--- a/setup/zpush.sh
+++ b/setup/zpush.sh
@@ -75,6 +75,7 @@ rm -f /usr/local/lib/z-push/autodiscover/config.php
 cp conf/zpush/autodiscover_config.php /usr/local/lib/z-push/autodiscover/config.php
 sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /usr/local/lib/z-push/autodiscover/config.php
 sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/autodiscover/config.php
+sed -i "s/define('LOGAUTHFAIL', .*/define('LOGAUTHFAIL', true);/" /usr/local/lib/z-push/config.php
 
 # Some directories it will use.
 
diff --git a/tests/suites/_init_miabldap.sh b/tests/suites/_init_miabldap.sh
index 5c2d4ef7..47298d95 100644
--- a/tests/suites/_init_miabldap.sh
+++ b/tests/suites/_init_miabldap.sh
@@ -12,7 +12,7 @@ set +eu
 
 MIAB_DIR=".."
 PYMAIL="./test_mail.py"
-
+EDITCONF="../tools/editconf.py"
 
 # options
 SKIP_REMOTE_SMTP_TESTS=no
diff --git a/tests/suites/z-push.sh b/tests/suites/z-push.sh
index 8c9057b0..3ea9dc77 100644
--- a/tests/suites/z-push.sh
+++ b/tests/suites/z-push.sh
@@ -2,7 +2,7 @@
 
 
 test_zpush_logon() {
-    test_start "zpush-logon"
+    test_start "logon"
 
     # create regular user alice
     local alice="alice@somedomain.com"
@@ -62,9 +62,143 @@ test_zpush_logon() {
 }
 
 
+test_zpush_fail2ban() {
+    test_start "fail2ban"
+    
+    # create regular user with password "alice"
+    local alice="alice@somedomain.com"
+    local alice_pw="alice"
+    create_user "$alice" "$alice_pw"
+
+    # The default fail2ban configuration ignores failed logins coming
+    # from our private ip and localhost. Change it so that it does not
+    # ignore the private ip in the z-push configuration only. Also
+    # change the allowed number of failures to a lower value to speed
+    # up the tests.
+    
+    record "[override default fail2ban options]"
+    local fail2ban_conf_temp="/tmp/runner_zpush_fail2ban.conf"
+    if [ -e "$fail2ban_conf_temp" ]; then
+        # if this test was somehow interrupted, the temp still exists
+        record "1. restore /etc/fail2ban/jail.d/mailinabox.conf"
+        cp "$fail2ban_conf_temp" "/etc/fail2ban/jail.d/mailinabox.conf" 1>>$TEST_OF 2>&1 || test_failure "Unable to setup test - could not restore fail2ban config"
+    else
+        record "1. duplicate /etc/fail2ban/jail.d/mailinabox.conf"
+        cp --no-clobber /etc/fail2ban/jail.d/mailinabox.conf $fail2ban_conf_temp 1>>$TEST_OF 2>&1 || test_failure "Unable to setup test - could not copy fail2ban config"
+    fi    
+
+    if ! have_test_failures; then
+        record "2. edit /etc/fail2ban/jail.d/mailinabox.conf"
+        $EDITCONF /etc/fail2ban/jail.d/mailinabox.conf \
+                  -ini-section z-push \
+                  "ignoreip=127.0.0.1/8 ::1" \
+                  "maxretry=5" >>$TEST_OF 2>&1 ||
+            test_failure "Unable to setup test - changing fail2ban config failed"
+        if ! have_test_failures; then
+            record "3. reload fail2ban"
+            systemctl reload fail2ban >>$TEST_OF 2>&1 || test_failure "Unable to setup test - reloading fail2ban failed"
+        fi
+        
+        # reset fail2ban - unban all
+        if ! have_test_failures; then
+            record "4. unban all"
+            fail2ban-client unban --all >>$TEST_OF 2>&1 ||
+                test_failure "Unable to setup test - executing unban --all failed"
+        fi
+    fi
+    
+    if have_test_failures; then
+        test_end
+        return
+    fi
+
+
+    # log in a bunch of times with wrong password
+    local devid="device1"
+    local devtype="iPhone"
+    local n=0 t1 t2 t
+    local total=10
+    local banned=no
+    local code=0
+
+    start_log_capture
+    
+    record "[log in $total times with wrong password]"
+    while ! have_test_failures && [ $n -lt $total ]; do
+        t1=$(date +%s)
+        rest_urlencoded POST "https://$PRIVATE_IP/Microsoft-Server-ActiveSync?Cmd=Ping&DeviceId=$devid&DeviceType=$devtype" "$alice" "bad-alice" --insecure 2>>$TEST_OF
+        code=$?
+        t2=$(date +%s)
+        let t="$t2 - $t1"
+        record "TRY $n (${t}s): result code $code"
+        if [ $code -eq 0 ]; then
+            test_failure "Unexpected logon success"
+            continue
+        elif grep -F 'code 7' <<<"$REST_ERROR" >/dev/null; then
+            # curl error for connection refused
+            record "BANNED!"
+            banned=yes
+            break
+        elif [ $REST_HTTP_CODE -eq 401 ]; then
+            # assume a logon failure, reset log monitor
+            check_logs false zpush nginx_access
+            start_log_capture
+        else
+            test_failure "Error in REST call to z-push: $REST_ERROR"
+            assert_check_logs zpush nginx_access
+            continue
+        fi
+        record "$REST_OUTPUT"
+        let n+=1
+    done
+
+    if ! have_test_failures; then
+        if [ "$banned" == "no" ]; then
+            test_failure "Multiple failed logons did not ban ip"
+
+        else
+            record "[logging in with correct password should also fail]"
+            rest_urlencoded POST "https://$PRIVATE_IP/Microsoft-Server-ActiveSync?Cmd=Ping&DeviceId=$devid&DeviceType=$devtype" "$alice" "$alice_pw" --insecure 2>>$TEST_OF
+            code=$?
+            record "result: $code"
+            if [ $code -eq 0 ]; then
+                test_failure "Expected user logon to fail due to ban"
+            elif grep -F 'code 7' <<<"$REST_ERROR" >/dev/null; then
+                # curl error for connection refused
+                record "OK: banned: $REST_ERROR"
+            else
+                test_failure "Error in REST call to z-push: $REST_ERROR"
+            fi
+        fi
+    fi
+
+    # delete alice
+    delete_user "$alice"
+    
+    # reset fail2ban
+    record "[reset fail2ban config changes]"
+    record "restore /etc/fail2ban/jail.d/mailinabox.conf"
+    cp $fail2ban_conf_temp /etc/fail2ban/jail.d/mailinabox.conf
+    if [ $? -ne 0 ]; then
+        test_failure "Unable to restore fail2ban config"
+    else
+        systemctl reload fail2ban >>$TEST_OF 2>&1 ||
+            test_failure "Unable reload fail2ban"
+    fi
+    rm -f $fail2ban_conf_temp
+
+    fail2ban-client unban --all >>$TEST_OF 2>&1 ||
+        test_failure "Unable to execute unban --all"
+        
+    # done
+    test_end
+}
+
+
 suite_start "z-push" zpush_start
 
 test_zpush_logon
+test_zpush_fail2ban
 
 suite_end zpush_end
 
diff --git a/tools/editconf.py b/tools/editconf.py
index c641a3af..caae003d 100755
--- a/tools/editconf.py
+++ b/tools/editconf.py
@@ -40,9 +40,11 @@ settings = sys.argv[2:]
 delimiter = "="
 delimiter_re = r"\s*=\s*"
 erase_setting = False
+erase_setting_via_comment = True
 comment_char = "#"
 folded_lines = False
 testing = False
+ini_section = None
 while settings[0][0] == "-" and settings[0] != "--":
 	opt = settings.pop(0)
 	if opt == "-s":
@@ -52,12 +54,18 @@ while settings[0][0] == "-" and settings[0] != "--":
 	elif opt == "-e":
 		# Erase settings that have empty values.
 		erase_setting = True
+	elif opt == "-E":
+		# Erase settings (remove from file) that have empty values.
+		erase_setting = True
+		erase_setting_via_comment = False
 	elif opt == "-w":
 		# Line folding is possible in this file.
 		folded_lines = True
 	elif opt == "-c":
 		# Specifies a different comment character.
 		comment_char = settings.pop(0)
+	elif opt == "-ini-section":
+		ini_section = settings.pop(0)
 	elif opt == "-t":
 		testing = True
 	else:
@@ -77,6 +85,7 @@ for setting in settings:
 found = set()
 buf = ""
 input_lines = list(open(filename))
+cur_section = None
 
 while len(input_lines) > 0:
 	line = input_lines.pop(0)
@@ -87,6 +96,24 @@ while len(input_lines) > 0:
 		while len(input_lines) > 0 and input_lines[0][0] in " \t":
 			line += input_lines.pop(0)
 
+	# If an ini file, keep track of what section we're in
+	if ini_section and line.startswith('[') and line.strip().endswith(']'):
+		if cur_section == ini_section.lower():
+			# Put any settings we didn't see at the end of the section.
+			for i in range(len(settings)):
+				if i not in found:
+					name, val = settings[i].split("=", 1)
+					if not (not val and erase_setting):
+					        buf += name + delimiter + val + "\n"
+		cur_section = line.strip()[1:-1].strip().lower()
+		buf += line
+		continue
+
+	if ini_section and cur_section != ini_section.lower():
+		# we're not processing the desired section, just append
+		buf += line
+		continue
+
 	# See if this line is for any settings passed on the command line.
 	for i in range(len(settings)):
 		# Check if this line contain this setting from the command-line arguments.
@@ -112,7 +139,8 @@ while len(input_lines) > 0:
 		
 		# comment-out the existing line (also comment any folded lines)
 		if is_comment is None:
-			buf += comment_char + line.rstrip().replace("\n", "\n" + comment_char) + "\n"
+			if val or not erase_setting or erase_setting_via_comment:
+				buf += comment_char + line.rstrip().replace("\n", "\n" + comment_char) + "\n"
 		else:
 			# the line is already commented, pass it through
 			buf += line
@@ -135,11 +163,12 @@ while len(input_lines) > 0:
 		
 # Put any settings we didn't see at the end of the file,
 # except settings being cleared.
-for i in range(len(settings)):
-	if (i not in found):
-		name, val = settings[i].split("=", 1)
-		if not (not val and erase_setting):
-			buf += name + delimiter + val + "\n"
+if not ini_section or cur_section == ini_section.lower():
+        for i in range(len(settings)):
+                if (i not in found):
+                        name, val = settings[i].split("=", 1)
+                        if not (not val and erase_setting):
+                                buf += name + delimiter + val + "\n"
 
 if not testing:
 	# Write out the new file.