NobMail
Based on mailcow: dockerized
Repository composition by tracked source files.
Clone
https://nobgit.com/orgs/nobgit/nobmail.git
ssh://[email protected]:2222/orgs/nobgit/nobmail.git
Commit
[Netfilter] add mailcow isolation rule to MAILCOW chain
[Netfilter] add mailcow rule to docker-user chain [Netfilter] add mailcow isolation rule to MAILCOW chain [Netfilter] add mailcow isolation rule to MAILCOW chain [Netfilter] set mailcow isolation rule before redis [Netfilter] clear bans in redis after connecting [Netfilter] simplify mailcow isolation rule for compatibility with iptables-nft [Netfilter] stop container after mariadb, redis, dovecot, solr [Netfilter] simplify mailcow isolation rule for compatibility with iptables-nft [Netfilter] add exception for mailcow isolation rule for HA setups [Netfilter] add exception for mailcow isolation rule for HA setups [Netfilter] add DISABLE_NETFILTER_ISOLATION_RULE [Netfilter] fix wrong var name [Netfilter] add DISABLE_NETFILTER_ISOLATION_RULE to update and generate_config sh
b236fd3a
.gitignore | 1 +
data/Dockerfiles/dovecot/docker-entrypoint.sh | 9 ++
data/Dockerfiles/netfilter/main.py | 90 ++++++++------
data/Dockerfiles/netfilter/modules/IPTables.py | 39 ++++++
data/Dockerfiles/netfilter/modules/Logger.py | 3 +-
data/Dockerfiles/netfilter/modules/NFTables.py | 165 ++++++++++++++++++++++++-
data/conf/dovecot/dovecot.conf | 3 +
docker-compose.yml | 16 ++-
generate_config.sh | 3 +
update.sh | 8 ++
10 files changed, 290 insertions(+), 47 deletions(-)
Diff
diff --git a/.gitignore b/.gitignore
index 3595ecb1..e25639a7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@ data/conf/dovecot/acl_anyone
data/conf/dovecot/dovecot-master.passwd
data/conf/dovecot/dovecot-master.userdb
data/conf/dovecot/extra.conf
+data/conf/dovecot/mail_replica.conf
data/conf/dovecot/global_sieve_*
data/conf/dovecot/last_login
data/conf/dovecot/lua
diff --git a/data/Dockerfiles/dovecot/docker-entrypoint.sh b/data/Dockerfiles/dovecot/docker-entrypoint.sh
index f1e2e966..a9545f33 100755
--- a/data/Dockerfiles/dovecot/docker-entrypoint.sh
+++ b/data/Dockerfiles/dovecot/docker-entrypoint.sh
@@ -335,6 +335,15 @@ sys.exit()
EOF
fi
+# Set mail_replica for HA setups
+if [[ -n ${MAILCOW_REPLICA_IP} && -n ${DOVEADM_REPLICA_PORT} ]]; then
+ cat <<EOF > /etc/dovecot/mail_replica.conf
+# Autogenerated by mailcow
+mail_replica = tcp:${MAILCOW_REPLICA_IP}:${DOVEADM_REPLICA_PORT}
+EOF
+fi
+
+
# 401 is user dovecot
if [[ ! -s /mail_crypt/ecprivkey.pem || ! -s /mail_crypt/ecpubkey.pem ]]; then
openssl ecparam -name prime256v1 -genkey | openssl pkey -out /mail_crypt/ecprivkey.pem
diff --git a/data/Dockerfiles/netfilter/main.py b/data/Dockerfiles/netfilter/main.py
index d5c1db50..8480c9ef 100644
--- a/data/Dockerfiles/netfilter/main.py
+++ b/data/Dockerfiles/netfilter/main.py
@@ -21,28 +21,6 @@ from modules.IPTables import IPTables
from modules.NFTables import NFTables
-# connect to redis
-while True:
- try:
- redis_slaveof_ip = os.getenv('REDIS_SLAVEOF_IP', '')
- redis_slaveof_port = os.getenv('REDIS_SLAVEOF_PORT', '')
- if "".__eq__(redis_slaveof_ip):
- r = redis.StrictRedis(host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0)
- else:
- r = redis.StrictRedis(host=redis_slaveof_ip, decode_responses=True, port=redis_slaveof_port, db=0)
- r.ping()
- except Exception as ex:
- print('%s - trying again in 3 seconds' % (ex))
- time.sleep(3)
- else:
- break
-pubsub = r.pubsub()
-
-# rename fail2ban to netfilter
-if r.exists('F2B_LOG'):
- r.rename('F2B_LOG', 'NETFILTER_LOG')
-
-
# globals
WHITELIST = []
BLACKLIST= []
@@ -50,18 +28,8 @@ bans = {}
quit_now = False
exit_code = 0
lock = Lock()
-
-
-# init Logger
-logger = Logger(r)
-# init backend
-backend = sys.argv[1]
-if backend == "nftables":
- logger.logInfo('Using NFTables backend')
- tables = NFTables("MAILCOW", logger)
-else:
- logger.logInfo('Using IPTables backend')
- tables = IPTables("MAILCOW", logger)
+chain_name = "MAILCOW"
+r = None
def refreshF2boptions():
@@ -250,9 +218,10 @@ def clear():
with lock:
tables.clearIPv4Table()
tables.clearIPv6Table()
- r.delete('F2B_ACTIVE_BANS')
- r.delete('F2B_PERM_BANS')
- pubsub.unsubscribe()
+ if r:
+ r.delete('F2B_ACTIVE_BANS')
+ r.delete('F2B_PERM_BANS')
+ pubsub.unsubscribe()
def watch():
logger.logInfo('Watching Redis channel F2B_CHANNEL')
@@ -409,15 +378,60 @@ def quit(signum, frame):
if __name__ == '__main__':
- refreshF2boptions()
+ # init Logger
+ logger = Logger(None)
+
+ # init backend
+ backend = sys.argv[1]
+ if backend == "nftables":
+ logger.logInfo('Using NFTables backend')
+ tables = NFTables(chain_name, logger)
+ else:
+ logger.logInfo('Using IPTables backend')
+ tables = IPTables(chain_name, logger)
+
# In case a previous session was killed without cleanup
clear()
+
# Reinit MAILCOW chain
# Is called before threads start, no locking
logger.logInfo("Initializing mailcow netfilter chain")
tables.initChainIPv4()
tables.initChainIPv6()
+ if os.getenv("DISABLE_NETFILTER_ISOLATION_RULE").lower() in ("y", "yes"):
+ logger.logInfo(f"Skipping {chain_name} isolation")
+ else:
+ logger.logInfo(f"Setting {chain_name} isolation")
+ tables.create_mailcow_isolation_rule("br-mailcow", [3306, 6379, 8983, 12345], os.getenv("MAILCOW_REPLICA_IP"))
+
+ # connect to redis
+ while True:
+ try:
+ redis_slaveof_ip = os.getenv('REDIS_SLAVEOF_IP', '')
+ redis_slaveof_port = os.getenv('REDIS_SLAVEOF_PORT', '')
+ if "".__eq__(redis_slaveof_ip):
+ r = redis.StrictRedis(host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0)
+ else:
+ r = redis.StrictRedis(host=redis_slaveof_ip, decode_responses=True, port=redis_slaveof_port, db=0)
+ r.ping()
+ except Exception as ex:
+ print('%s - trying again in 3 seconds' % (ex))
+ time.sleep(3)
+ else:
+ break
+ pubsub = r.pubsub()
+ Logger.r = r
+
+ # rename fail2ban to netfilter
+ if r.exists('F2B_LOG'):
+ r.rename('F2B_LOG', 'NETFILTER_LOG')
+ # clear bans in redis
+ r.delete('F2B_ACTIVE_BANS')
+ r.delete('F2B_PERM_BANS')
+
+ refreshF2boptions()
+
watch_thread = Thread(target=watch)
watch_thread.daemon = True
watch_thread.start()
diff --git a/data/Dockerfiles/netfilter/modules/IPTables.py b/data/Dockerfiles/netfilter/modules/IPTables.py
index c60ecc61..29a9fb65 100644
--- a/data/Dockerfiles/netfilter/modules/IPTables.py
+++ b/data/Dockerfiles/netfilter/modules/IPTables.py
@@ -1,5 +1,6 @@
import iptc
import time
+import os
class IPTables:
def __init__(self, chain_name, logger):
@@ -211,3 +212,41 @@ class IPTables:
target = rule.create_target("SNAT")
target.to_source = snat_target
return rule
+
+ def create_mailcow_isolation_rule(self, _interface:str, _dports:list, _allow:str = ""):
+ try:
+ chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name)
+
+ # insert mailcow isolation rule
+ rule = iptc.Rule()
+ rule.in_interface = f'! {_interface}'
+ rule.out_interface = _interface
+ rule.protocol = 'tcp'
+ rule.create_target("DROP")
+ match = rule.create_match("multiport")
+ match.dports = ','.join(map(str, _dports))
+
+ if rule in chain.rules:
+ chain.delete_rule(rule)
+ chain.insert_rule(rule, position=0)
+
+ # insert mailcow isolation exception rule
+ if _allow != "":
+ rule = iptc.Rule()
+ rule.src = _allow
+ rule.in_interface = f'! {_interface}'
+ rule.out_interface = _interface
+ rule.protocol = 'tcp'
+ rule.create_target("ACCEPT")
+ match = rule.create_match("multiport")
+ match.dports = ','.join(map(str, _dports))
+
+ if rule in chain.rules:
+ chain.delete_rule(rule)
+ chain.insert_rule(rule, position=0)
+
+
+ return True
+ except Exception as e:
+ self.logger.logCrit(f"Error adding {self.chain_name} isolation: {e}")
+ return False
\ No newline at end of file
diff --git a/data/Dockerfiles/netfilter/modules/Logger.py b/data/Dockerfiles/netfilter/modules/Logger.py
index d60d52fa..2a40de0c 100644
--- a/data/Dockerfiles/netfilter/modules/Logger.py
+++ b/data/Dockerfiles/netfilter/modules/Logger.py
@@ -10,7 +10,8 @@ class Logger:
tolog['time'] = int(round(time.time()))
tolog['priority'] = priority
tolog['message'] = message
- self.r.lpush('NETFILTER_LOG', json.dumps(tolog, ensure_ascii=False))
+ if self.r:
+ self.r.lpush('NETFILTER_LOG', json.dumps(tolog, ensure_ascii=False))
print(message)
def logWarn(self, message):
diff --git a/data/Dockerfiles/netfilter/modules/NFTables.py b/data/Dockerfiles/netfilter/modules/NFTables.py
index d341dc36..e8e02c47 100644
--- a/data/Dockerfiles/netfilter/modules/NFTables.py
+++ b/data/Dockerfiles/netfilter/modules/NFTables.py
@@ -1,5 +1,6 @@
import nftables
import ipaddress
+import os
class NFTables:
def __init__(self, chain_name, logger):
@@ -266,6 +267,17 @@ class NFTables:
return self.nft_exec_dict(delete_command)
+ def delete_filter_rule(self, _family:str, _chain: str, _handle:str):
+ delete_command = self.get_base_dict()
+ _rule_opts = {'family': _family,
+ 'table': 'filter',
+ 'chain': _chain,
+ 'handle': _handle }
+ _delete = {'delete': {'rule': _rule_opts} }
+ delete_command["nftables"].append(_delete)
+
+ return self.nft_exec_dict(delete_command)
+
def snat_rule(self, _family: str, snat_target: str, source_address: str):
chain_name = self.nft_chain_names[_family]['nat']['postrouting']
@@ -381,7 +393,7 @@ class NFTables:
break
return chain_handle
- def get_rules_handle(self, _family: str, _table: str, chain_name: str):
+ def get_rules_handle(self, _family: str, _table: str, chain_name: str, _comment_filter = "mailcow"):
rule_handle = []
# Command: 'nft list chain {family} {table} {chain_name}'
_chain_opts = {'family': _family, 'table': _table, 'name': chain_name}
@@ -397,7 +409,7 @@ class NFTables:
rule = _object["rule"]
if rule["family"] == _family and rule["table"] == _table and rule["chain"] == chain_name:
- if rule.get("comment") and rule["comment"] == "mailcow":
+ if rule.get("comment") and rule["comment"] == _comment_filter:
rule_handle.append(rule["handle"])
return rule_handle
@@ -493,3 +505,152 @@ class NFTables:
position+=1
return position if rule_found else False
+
+ def create_mailcow_isolation_rule(self, _interface:str, _dports:list, _allow:str = ""):
+ family = "ip"
+ table = "filter"
+ comment_filter_drop = "mailcow isolation"
+ comment_filter_allow = "mailcow isolation allow"
+ json_command = self.get_base_dict()
+
+ # Delete old mailcow isolation rules
+ handles = self.get_rules_handle(family, table, self.chain_name, comment_filter_drop)
+ for handle in handles:
+ self.delete_filter_rule(family, self.chain_name, handle)
+ handles = self.get_rules_handle(family, table, self.chain_name, comment_filter_allow)
+ for handle in handles:
+ self.delete_filter_rule(family, self.chain_name, handle)
+
+ # insert mailcow isolation rule
+ _match_dict_drop = [
+ {
+ "match": {
+ "op": "!=",
+ "left": {
+ "meta": {
+ "key": "iifname"
+ }
+ },
+ "right": _interface
+ }
+ },
+ {
+ "match": {
+ "op": "==",
+ "left": {
+ "meta": {
+ "key": "oifname"
+ }
+ },
+ "right": _interface
+ }
+ },
+ {
+ "match": {
+ "op": "==",
+ "left": {
+ "payload": {
+ "protocol": "tcp",
+ "field": "dport"
+ }
+ },
+ "right": {
+ "set": _dports
+ }
+ }
+ },
+ {
+ "counter": {
+ "packets": 0,
+ "bytes": 0
+ }
+ },
+ {
+ "drop": None
+ }
+ ]
+ rule_drop = { "insert": { "rule": {
+ "family": family,
+ "table": table,
+ "chain": self.chain_name,
+ "comment": comment_filter_drop,
+ "expr": _match_dict_drop
+ }}}
+ json_command["nftables"].append(rule_drop)
+
+ # insert mailcow isolation allow rule
+ if _allow != "":
+ _match_dict_allow = [
+ {
+ "match": {
+ "op": "==",
+ "left": {
+ "payload": {
+ "protocol": "ip",
+ "field": "saddr"
+ }
+ },
+ "right": _allow
+ }
+ },
+ {
+ "match": {
+ "op": "!=",
+ "left": {
+ "meta": {
+ "key": "iifname"
+ }
+ },
+ "right": _interface
+ }
+ },
+ {
+ "match": {
+ "op": "==",
+ "left": {
+ "meta": {
+ "key": "oifname"
+ }
+ },
+ "right": _interface
+ }
+ },
+ {
+ "match": {
+ "op": "==",
+ "left": {
+ "payload": {
+ "protocol": "tcp",
+ "field": "dport"
+ }
+ },
+ "right": {
+ "set": _dports
+ }
+ }
+ },
+ {
+ "counter": {
+ "packets": 0,
+ "bytes": 0
+ }
+ },
+ {
+ "accept": None
+ }
+ ]
+ rule_allow = { "insert": { "rule": {
+ "family": family,
+ "table": table,
+ "chain": self.chain_name,
+ "comment": comment_filter_allow,
+ "expr": _match_dict_allow
+ }}}
+ json_command["nftables"].append(rule_allow)
+
+ success = self.nft_exec_dict(json_command)
+ if success == False:
+ self.logger.logCrit(f"Error adding {self.chain_name} isolation")
+ return False
+
+ return True
\ No newline at end of file
diff --git a/data/conf/dovecot/dovecot.conf b/data/conf/dovecot/dovecot.conf
index 159e39f4..729686fb 100644
--- a/data/conf/dovecot/dovecot.conf
+++ b/data/conf/dovecot/dovecot.conf
@@ -247,6 +247,9 @@ plugin {
mail_log_events = delete undelete expunge copy mailbox_delete mailbox_rename
mail_log_fields = uid box msgid size
mail_log_cached_only = yes
+
+ # Try set mail_replica
+ !include_try /etc/dovecot/mail_replica.conf
}
service quota-warning {
executable = script /usr/local/bin/quota_notify.py
diff --git a/docker-compose.yml b/docker-compose.yml
index c1883f90..fa92546a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -21,6 +21,7 @@ services:
image: mariadb:10.5
depends_on:
- unbound-mailcow
+ - netfilter-mailcow
stop_grace_period: 45s
volumes:
- mysql-vol-1:/var/lib/mysql/
@@ -46,6 +47,8 @@ services:
volumes:
- redis-vol-1:/data/
restart: always
+ depends_on:
+ - netfilter-mailcow
ports:
- "${REDIS_PORT:-127.0.0.1:7654}:6379"
environment:
@@ -222,6 +225,7 @@ services:
image: mailcow/dovecot:1.27
depends_on:
- mysql-mailcow
+ - netfilter-mailcow
dns:
- ${IPV4_NETWORK:-172.22.1}.254
cap_add:
@@ -242,6 +246,8 @@ services:
environment:
- DOVECOT_MASTER_USER=${DOVECOT_MASTER_USER:-}
- DOVECOT_MASTER_PASS=${DOVECOT_MASTER_PASS:-}
+ - MAILCOW_REPLICA_IP=${MAILCOW_REPLICA_IP:-}
+ - DOVEADM_REPLICA_PORT=${DOVEADM_REPLICA_PORT:-}
- LOG_LINES=${LOG_LINES:-9999}
- DBNAME=${DBNAME}
- DBUSER=${DBUSER}
@@ -437,12 +443,6 @@ services:
netfilter-mailcow:
image: mailcow/netfilter:1.55
stop_grace_period: 30s
- depends_on:
- - dovecot-mailcow
- - postfix-mailcow
- - sogo-mailcow
- - php-fpm-mailcow
- - redis-mailcow
restart: always
privileged: true
environment:
@@ -453,6 +453,8 @@ services:
- SNAT6_TO_SOURCE=${SNAT6_TO_SOURCE:-n}
- REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
- REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
+ - MAILCOW_REPLICA_IP=${MAILCOW_REPLICA_IP:-}
+ - DISABLE_NETFILTER_ISOLATION_RULE=${DISABLE_NETFILTER_ISOLATION_RULE:-n}
network_mode: "host"
volumes:
- /lib/modules:/lib/modules:ro
@@ -553,6 +555,8 @@ services:
solr-mailcow:
image: mailcow/solr:1.8.2
restart: always
+ depends_on:
+ - netfilter-mailcow
volumes:
- solr-vol-1:/opt/solr/server/solr/dovecot-fts/data
ports:
diff --git a/generate_config.sh b/generate_config.sh
index e62d1689..05d9ee2f 100755
--- a/generate_config.sh
+++ b/generate_config.sh
@@ -494,6 +494,9 @@ WEBAUTHN_ONLY_TRUSTED_VENDORS=n
# Otherwise it will work normally.
SPAMHAUS_DQS_KEY=
+# Prevent netfilter from setting an iptables/nftables rule to isolate the mailcow docker network - y/n
+# CAUTION: Disabling this may expose container ports to other neighbors on the same subnet, even if the ports are bound to localhost
+DISABLE_NETFILTER_ISOLATION_RULE=n
EOF
mkdir -p data/assets/ssl
diff --git a/update.sh b/update.sh
index 5df32e00..f1e31652 100755
--- a/update.sh
+++ b/update.sh
@@ -481,6 +481,7 @@ CONFIG_ARRAY=(
"WEBAUTHN_ONLY_TRUSTED_VENDORS"
"SPAMHAUS_DQS_KEY"
"SKIP_UNBOUND_HEALTHCHECK"
+ "DISABLE_NETFILTER_ISOLATION_RULE"
)
detect_bad_asn
@@ -754,6 +755,13 @@ for option in ${CONFIG_ARRAY[@]}; do
echo '# Skip Unbound (DNS Resolver) Healthchecks (NOT Recommended!) - y/n' >> mailcow.conf
echo 'SKIP_UNBOUND_HEALTHCHECK=n' >> mailcow.conf
fi
+ elif [[ ${option} == "DISABLE_NETFILTER_ISOLATION_RULE" ]]; then
+ if ! grep -q ${option} mailcow.conf; then
+ echo "Adding new option \"${option}\" to mailcow.conf"
+ echo '# Prevent netfilter from setting an iptables/nftables rule to isolate the mailcow docker network - y/n' >> mailcow.conf
+ echo '# CAUTION: Disabling this may expose container ports to other neighbors on the same subnet, even if the ports are bound to localhost' >> mailcow.conf
+ echo 'DISABLE_NETFILTER_ISOLATION_RULE=n' >> mailcow.conf
+ fi
elif ! grep -q ${option} mailcow.conf; then
echo "Adding new option \"${option}\" to mailcow.conf"
echo "${option}=n" >> mailcow.conf