瀏覽代碼

[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
FreddleSpl0it 1 年之前
父節點
當前提交
b236fd3ac6

+ 1 - 0
.gitignore

@@ -13,6 +13,7 @@ data/conf/dovecot/acl_anyone
 data/conf/dovecot/dovecot-master.passwd
 data/conf/dovecot/dovecot-master.passwd
 data/conf/dovecot/dovecot-master.userdb
 data/conf/dovecot/dovecot-master.userdb
 data/conf/dovecot/extra.conf
 data/conf/dovecot/extra.conf
+data/conf/dovecot/mail_replica.conf
 data/conf/dovecot/global_sieve_*
 data/conf/dovecot/global_sieve_*
 data/conf/dovecot/last_login
 data/conf/dovecot/last_login
 data/conf/dovecot/lua
 data/conf/dovecot/lua

+ 9 - 0
data/Dockerfiles/dovecot/docker-entrypoint.sh

@@ -335,6 +335,15 @@ sys.exit()
 EOF
 EOF
 fi
 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
 # 401 is user dovecot
 if [[ ! -s /mail_crypt/ecprivkey.pem || ! -s /mail_crypt/ecpubkey.pem ]]; then
 if [[ ! -s /mail_crypt/ecprivkey.pem || ! -s /mail_crypt/ecpubkey.pem ]]; then
 	openssl ecparam -name prime256v1 -genkey | openssl pkey -out /mail_crypt/ecprivkey.pem
 	openssl ecparam -name prime256v1 -genkey | openssl pkey -out /mail_crypt/ecprivkey.pem

+ 52 - 38
data/Dockerfiles/netfilter/main.py

@@ -21,28 +21,6 @@ from modules.IPTables import IPTables
 from modules.NFTables import NFTables
 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
 # globals
 WHITELIST = []
 WHITELIST = []
 BLACKLIST= []
 BLACKLIST= []
@@ -50,18 +28,8 @@ bans = {}
 quit_now = False
 quit_now = False
 exit_code = 0
 exit_code = 0
 lock = Lock()
 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():
 def refreshF2boptions():
@@ -250,9 +218,10 @@ def clear():
   with lock:
   with lock:
     tables.clearIPv4Table()
     tables.clearIPv4Table()
     tables.clearIPv6Table()
     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():
 def watch():
   logger.logInfo('Watching Redis channel F2B_CHANNEL')
   logger.logInfo('Watching Redis channel F2B_CHANNEL')
@@ -409,15 +378,60 @@ def quit(signum, frame):
 
 
 
 
 if __name__ == '__main__':
 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
   # In case a previous session was killed without cleanup
   clear()
   clear()
+
   # Reinit MAILCOW chain
   # Reinit MAILCOW chain
   # Is called before threads start, no locking
   # Is called before threads start, no locking
   logger.logInfo("Initializing mailcow netfilter chain")
   logger.logInfo("Initializing mailcow netfilter chain")
   tables.initChainIPv4()
   tables.initChainIPv4()
   tables.initChainIPv6()
   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 = Thread(target=watch)
   watch_thread.daemon = True
   watch_thread.daemon = True
   watch_thread.start()
   watch_thread.start()

+ 39 - 0
data/Dockerfiles/netfilter/modules/IPTables.py

@@ -1,5 +1,6 @@
 import iptc
 import iptc
 import time
 import time
+import os
 
 
 class IPTables:
 class IPTables:
   def __init__(self, chain_name, logger):
   def __init__(self, chain_name, logger):
@@ -211,3 +212,41 @@ class IPTables:
     target = rule.create_target("SNAT")
     target = rule.create_target("SNAT")
     target.to_source = snat_target
     target.to_source = snat_target
     return rule
     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

+ 2 - 1
data/Dockerfiles/netfilter/modules/Logger.py

@@ -10,7 +10,8 @@ class Logger:
     tolog['time'] = int(round(time.time()))
     tolog['time'] = int(round(time.time()))
     tolog['priority'] = priority
     tolog['priority'] = priority
     tolog['message'] = message
     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)
     print(message)
 
 
   def logWarn(self, message):
   def logWarn(self, message):

+ 163 - 2
data/Dockerfiles/netfilter/modules/NFTables.py

@@ -1,5 +1,6 @@
 import nftables
 import nftables
 import ipaddress
 import ipaddress
+import os
 
 
 class NFTables:
 class NFTables:
   def __init__(self, chain_name, logger):
   def __init__(self, chain_name, logger):
@@ -266,6 +267,17 @@ class NFTables:
 
 
     return self.nft_exec_dict(delete_command)
     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):
   def snat_rule(self, _family: str, snat_target: str, source_address: str):
     chain_name = self.nft_chain_names[_family]['nat']['postrouting']
     chain_name = self.nft_chain_names[_family]['nat']['postrouting']
 
 
@@ -381,7 +393,7 @@ class NFTables:
           break
           break
     return chain_handle
     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 = []
     rule_handle = []
     # Command: 'nft list chain {family} {table} {chain_name}'
     # Command: 'nft list chain {family} {table} {chain_name}'
     _chain_opts = {'family': _family, 'table': _table, 'name': chain_name}
     _chain_opts = {'family': _family, 'table': _table, 'name': chain_name}
@@ -397,7 +409,7 @@ class NFTables:
 
 
         rule = _object["rule"]
         rule = _object["rule"]
         if rule["family"] == _family and rule["table"] == _table and rule["chain"] == chain_name:
         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"])
             rule_handle.append(rule["handle"])
     return rule_handle
     return rule_handle
 
 
@@ -493,3 +505,152 @@ class NFTables:
         position+=1
         position+=1
 
 
     return position if rule_found else False
     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

+ 3 - 0
data/conf/dovecot/dovecot.conf

@@ -247,6 +247,9 @@ plugin {
   mail_log_events = delete undelete expunge copy mailbox_delete mailbox_rename
   mail_log_events = delete undelete expunge copy mailbox_delete mailbox_rename
   mail_log_fields = uid box msgid size
   mail_log_fields = uid box msgid size
   mail_log_cached_only = yes
   mail_log_cached_only = yes
+
+  # Try set mail_replica
+  !include_try /etc/dovecot/mail_replica.conf
 }
 }
 service quota-warning {
 service quota-warning {
   executable = script /usr/local/bin/quota_notify.py
   executable = script /usr/local/bin/quota_notify.py

+ 10 - 6
docker-compose.yml

@@ -21,6 +21,7 @@ services:
       image: mariadb:10.5
       image: mariadb:10.5
       depends_on:
       depends_on:
         - unbound-mailcow
         - unbound-mailcow
+        - netfilter-mailcow
       stop_grace_period: 45s
       stop_grace_period: 45s
       volumes:
       volumes:
         - mysql-vol-1:/var/lib/mysql/
         - mysql-vol-1:/var/lib/mysql/
@@ -46,6 +47,8 @@ services:
       volumes:
       volumes:
         - redis-vol-1:/data/
         - redis-vol-1:/data/
       restart: always
       restart: always
+      depends_on:
+        - netfilter-mailcow
       ports:
       ports:
         - "${REDIS_PORT:-127.0.0.1:7654}:6379"
         - "${REDIS_PORT:-127.0.0.1:7654}:6379"
       environment:
       environment:
@@ -222,6 +225,7 @@ services:
       image: mailcow/dovecot:1.27
       image: mailcow/dovecot:1.27
       depends_on:
       depends_on:
         - mysql-mailcow
         - mysql-mailcow
+        - netfilter-mailcow
       dns:
       dns:
         - ${IPV4_NETWORK:-172.22.1}.254
         - ${IPV4_NETWORK:-172.22.1}.254
       cap_add:
       cap_add:
@@ -242,6 +246,8 @@ services:
       environment:
       environment:
         - DOVECOT_MASTER_USER=${DOVECOT_MASTER_USER:-}
         - DOVECOT_MASTER_USER=${DOVECOT_MASTER_USER:-}
         - DOVECOT_MASTER_PASS=${DOVECOT_MASTER_PASS:-}
         - DOVECOT_MASTER_PASS=${DOVECOT_MASTER_PASS:-}
+        - MAILCOW_REPLICA_IP=${MAILCOW_REPLICA_IP:-}
+        - DOVEADM_REPLICA_PORT=${DOVEADM_REPLICA_PORT:-}
         - LOG_LINES=${LOG_LINES:-9999}
         - LOG_LINES=${LOG_LINES:-9999}
         - DBNAME=${DBNAME}
         - DBNAME=${DBNAME}
         - DBUSER=${DBUSER}
         - DBUSER=${DBUSER}
@@ -437,12 +443,6 @@ services:
     netfilter-mailcow:
     netfilter-mailcow:
       image: mailcow/netfilter:1.55
       image: mailcow/netfilter:1.55
       stop_grace_period: 30s
       stop_grace_period: 30s
-      depends_on:
-        - dovecot-mailcow
-        - postfix-mailcow
-        - sogo-mailcow
-        - php-fpm-mailcow
-        - redis-mailcow
       restart: always
       restart: always
       privileged: true
       privileged: true
       environment:
       environment:
@@ -453,6 +453,8 @@ services:
         - SNAT6_TO_SOURCE=${SNAT6_TO_SOURCE:-n}
         - SNAT6_TO_SOURCE=${SNAT6_TO_SOURCE:-n}
         - REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
         - REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
         - REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
         - REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
+        - MAILCOW_REPLICA_IP=${MAILCOW_REPLICA_IP:-}
+        - DISABLE_NETFILTER_ISOLATION_RULE=${DISABLE_NETFILTER_ISOLATION_RULE:-n}
       network_mode: "host"
       network_mode: "host"
       volumes:
       volumes:
         - /lib/modules:/lib/modules:ro
         - /lib/modules:/lib/modules:ro
@@ -553,6 +555,8 @@ services:
     solr-mailcow:
     solr-mailcow:
       image: mailcow/solr:1.8.2
       image: mailcow/solr:1.8.2
       restart: always
       restart: always
+      depends_on:
+        - netfilter-mailcow
       volumes:
       volumes:
         - solr-vol-1:/opt/solr/server/solr/dovecot-fts/data
         - solr-vol-1:/opt/solr/server/solr/dovecot-fts/data
       ports:
       ports:

+ 3 - 0
generate_config.sh

@@ -494,6 +494,9 @@ WEBAUTHN_ONLY_TRUSTED_VENDORS=n
 # Otherwise it will work normally.
 # Otherwise it will work normally.
 SPAMHAUS_DQS_KEY=
 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
 EOF
 
 
 mkdir -p data/assets/ssl
 mkdir -p data/assets/ssl

+ 8 - 0
update.sh

@@ -481,6 +481,7 @@ CONFIG_ARRAY=(
   "WEBAUTHN_ONLY_TRUSTED_VENDORS"
   "WEBAUTHN_ONLY_TRUSTED_VENDORS"
   "SPAMHAUS_DQS_KEY"
   "SPAMHAUS_DQS_KEY"
   "SKIP_UNBOUND_HEALTHCHECK"
   "SKIP_UNBOUND_HEALTHCHECK"
+  "DISABLE_NETFILTER_ISOLATION_RULE"
 )
 )
 
 
 detect_bad_asn
 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 (DNS Resolver) Healthchecks (NOT Recommended!) - y/n' >> mailcow.conf
       echo 'SKIP_UNBOUND_HEALTHCHECK=n' >> mailcow.conf
       echo 'SKIP_UNBOUND_HEALTHCHECK=n' >> mailcow.conf
     fi
     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
   elif ! grep -q ${option} mailcow.conf; then
     echo "Adding new option \"${option}\" to mailcow.conf"
     echo "Adding new option \"${option}\" to mailcow.conf"
     echo "${option}=n" >> mailcow.conf
     echo "${option}=n" >> mailcow.conf