public
nobgit
read
NobMail
Based on mailcow: dockerized
Languages
Repository composition by tracked source files.
PHP
49%
JavaScript
35%
HTML
9%
CSS
4%
Shell
2%
Python
1%
Lua
0%
Perl
0%
Ruby
0%
SCSS
0%
Create file
Wiki Documentation
Clone
https://nobgit.com/orgs/nobgit/nobmail.git
ssh://[email protected]:2222/orgs/nobgit/nobmail.git
Commit
Authentication rate limiting for Postfix, Dovecot and SOGo
88f94a2e
data/Dockerfiles/fail2ban/Dockerfile | 8 +++
data/Dockerfiles/fail2ban/logwatch.py | 103 ++++++++++++++++++++++++++++++++++
docker-compose.yml | 14 +++++
3 files changed, 125 insertions(+)
create mode 100644 data/Dockerfiles/fail2ban/Dockerfile
create mode 100755 data/Dockerfiles/fail2ban/logwatch.py
Diff
diff --git a/data/Dockerfiles/fail2ban/Dockerfile b/data/Dockerfiles/fail2ban/Dockerfile
new file mode 100644
index 00000000..93c4e924
--- /dev/null
+++ b/data/Dockerfiles/fail2ban/Dockerfile
@@ -0,0 +1,8 @@
+FROM python:2-alpine
+LABEL maintainer "Andre Peters <[email protected]>"
+
+RUN apk add -U --no-cache iptables ip6tables
+RUN pip install docker
+
+COPY logwatch.py /
+CMD ["python2", "-u", "/logwatch.py"]
diff --git a/data/Dockerfiles/fail2ban/logwatch.py b/data/Dockerfiles/fail2ban/logwatch.py
new file mode 100755
index 00000000..24b9fe6e
--- /dev/null
+++ b/data/Dockerfiles/fail2ban/logwatch.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python2
+
+import re
+import time
+import atexit
+import signal
+import ipaddress
+import subprocess
+from threading import Thread
+import docker
+
+RULES = {
+ 'mailcowdockerized_postfix-mailcow_1': 'warning: .*\[([0-9a-f\.:]+)\]: SASL .* authentication failed',
+ 'mailcowdockerized_dovecot-mailcow_1': '-login: Disconnected \(auth failed, .*\): user=.*, method=.*, rip=([0-9a-f\.:]+),',
+ 'mailcowdockerized_sogo-mailcow_1': 'SOGo.* Login from \'([0-9a-f\.:]+)\' for user .* might not have worked',
+}
+BAN_TIME = 1800
+MAX_ATTEMPTS = 10
+
+bans = {}
+quit_now = False
+
+def ban(address):
+ ip = ipaddress.ip_address(address.decode('ascii'))
+ if ip.is_private or ip.is_loopback:
+ return
+
+ net = ipaddress.ip_network((address + ('/24' if type(ip) is ipaddress.IPv4Address else '/64')).decode('ascii'), strict=False)
+ net = str(net)
+
+ if not net in bans or time.time() - bans[net]['last_attempt'] > BAN_TIME:
+ bans[net] = { 'attempts': 0 }
+
+ bans[net]['attempts'] += 1
+ bans[net]['last_attempt'] = time.time()
+
+ if bans[net]['attempts'] >= MAX_ATTEMPTS:
+ print "Banning %s" % net
+ if type(ip) is ipaddress.IPv4Address:
+ subprocess.call(["iptables", "-I", "INPUT", "-s", net, "-j", "REJECT"])
+ subprocess.call(["iptables", "-I", "FORWARD", "-s", net, "-j", "REJECT"])
+ else:
+ subprocess.call(["ip6tables", "-I", "INPUT", "-s", net, "-j", "REJECT"])
+ subprocess.call(["ip6tables", "-I", "FORWARD", "-s", net, "-j", "REJECT"])
+ else:
+ print "%d more attempts until %s is banned" % (MAX_ATTEMPTS - bans[net]['attempts'], net)
+
+def unban(net):
+ print "Unbanning %s" % net
+ if type(ipaddress.ip_network(net.decode('ascii'))) is ipaddress.IPv4Network:
+ subprocess.call(["iptables", "-D", "INPUT", "-s", net, "-j", "REJECT"])
+ subprocess.call(["iptables", "-D", "FORWARD", "-s", net, "-j", "REJECT"])
+ else:
+ subprocess.call(["ip6tables", "-D", "INPUT", "-s", net, "-j", "REJECT"])
+ subprocess.call(["ip6tables", "-D", "FORWARD", "-s", net, "-j", "REJECT"])
+ del bans[net]
+
+def quit(signum, frame):
+ global quit_now
+ quit_now = True
+
+def clear():
+ print "Clearing all bans"
+ for net in bans.copy():
+ unban(net)
+
+def watch(container):
+ print "Watching", container
+ client = docker.from_env()
+ for msg in client.containers.get(container).attach(stream=True, logs=False):
+ result = re.search(RULES[container], msg)
+ if result:
+ addr = result.group(1)
+ ban(addr)
+
+def autopurge():
+ while not quit_now:
+ for net in bans.copy():
+ if time.time() - bans[net]['last_attempt'] > BAN_TIME:
+ unban(net)
+ time.sleep(60)
+
+if __name__ == '__main__':
+ threads = []
+ for container in RULES:
+ threads.append(Thread(target=watch, args=(container,)))
+ threads[-1].daemon = True
+ threads[-1].start()
+
+ autopurge_thread = Thread(target=autopurge)
+ autopurge_thread.daemon = True
+ autopurge_thread.start()
+
+ signal.signal(signal.SIGTERM, quit)
+ atexit.register(clear)
+
+ while not quit_now:
+ for thread in threads:
+ if not thread.isAlive():
+ break
+ time.sleep(0.1)
+
+ clear()
diff --git a/docker-compose.yml b/docker-compose.yml
index d5c4ead0..bfb3df38 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -265,6 +265,20 @@ services:
aliases:
- nginx
+ fail2ban-mailcow:
+ image: mailcow/fail2ban
+ build: ./data/Dockerfiles/fail2ban
+ depends_on:
+ - dovecot-mailcow
+ - postfix-mailcow
+ - sogo-mailcow
+ restart: always
+ privileged: true
+ network_mode: "host"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - /lib/modules:/lib/modules:ro
+
ipv6nat:
image: robbertkl/ipv6nat
restart: always