Browse Source

[Netfilter] Rename fail2ban to netfilter, use iptables-python

andre.peters 7 years ago
parent
commit
38a819771b

+ 0 - 8
data/Dockerfiles/fail2ban/Dockerfile

@@ -1,8 +0,0 @@
-FROM python:2-alpine
-LABEL maintainer "Andre Peters <andre.peters@servercow.de>"
-
-RUN apk add -U --no-cache iptables ip6tables
-RUN pip install redis ipaddress
-
-COPY logwatch.py /
-CMD ["python2", "-u", "/logwatch.py"]

+ 9 - 0
data/Dockerfiles/netfilter/Dockerfile

@@ -0,0 +1,9 @@
+FROM alpine:3.7
+LABEL maintainer "Andre Peters <andre.peters@servercow.de>"
+
+RUN apk add -U python2 python-dev py-pip gcc musl-dev iptables ip6tables \
+  && pip2 install --upgrade python-iptables redis ipaddress \
+  && apk del python-dev py2-pip gcc
+
+COPY server.py /
+CMD ["python2", "-u", "/server.py"]

+ 268 - 192
data/Dockerfiles/fail2ban/logwatch.py → data/Dockerfiles/netfilter/server.py

@@ -1,192 +1,268 @@
-#!/usr/bin/env python2
-
-import re
-import os
-import time
-import atexit
-import signal
-import ipaddress
-import subprocess
-from threading import Thread
-import redis
-import time
-import json
-
-yes_regex = re.compile(r'([yY][eE][sS]|[yY])+$')
-if re.search(yes_regex, os.getenv('SKIP_FAIL2BAN', 0)):
-  print 'SKIP_FAIL2BAN=y, Skipping Fail2ban container...'
-  time.sleep(31536000)
-  raise SystemExit
-
-r = redis.StrictRedis(host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0)
-pubsub = r.pubsub()
-
-RULES = {}
-RULES[1] = 'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed'
-RULES[2] = '-login: Disconnected \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),'
-RULES[3] = '-login: Aborted login \(no auth .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
-RULES[4] = '-login: Aborted login \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
-RULES[5] = 'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked'
-RULES[6] = 'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)'
-
-r.setnx('F2B_BAN_TIME', '1800')
-r.setnx('F2B_MAX_ATTEMPTS', '10')
-r.setnx('F2B_RETRY_WINDOW', '600')
-r.setnx('F2B_NETBAN_IPV6', '64')
-r.setnx('F2B_NETBAN_IPV4', '24')
-
-bans = {}
-log = {}
-quit_now = False
-
-def ban(address):
-  BAN_TIME = int(r.get('F2B_BAN_TIME'))
-  MAX_ATTEMPTS = int(r.get('F2B_MAX_ATTEMPTS'))
-  RETRY_WINDOW = int(r.get('F2B_RETRY_WINDOW'))
-  WHITELIST = r.hgetall('F2B_WHITELIST')
-  NETBAN_IPV6 = '/' + str(r.get('F2B_NETBAN_IPV6'))
-  NETBAN_IPV4 = '/' + str(r.get('F2B_NETBAN_IPV4'))
-
-  ip = ipaddress.ip_address(address.decode('ascii'))
-  if type(ip) is ipaddress.IPv6Address and ip.ipv4_mapped:
-    ip = ip.ipv4_mapped
-    address = str(ip)
-  if ip.is_private or ip.is_loopback:
-    return
-
-  self_network = ipaddress.ip_network(address.decode('ascii'))
-  if WHITELIST:
-    for wl_key in WHITELIST:
-      wl_net = ipaddress.ip_network(wl_key.decode('ascii'), False)
-      if wl_net.overlaps(self_network):
-        log['time'] = int(round(time.time()))
-        log['priority'] = 'info'
-        log['message'] = 'Address %s is whitelisted by rule %s' % (self_network, wl_net)
-        r.lpush('F2B_LOG', json.dumps(log, ensure_ascii=False))
-        print 'Address %s is whitelisted by rule %s' % (self_network, wl_net)
-        return
-
-  net = ipaddress.ip_network((address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)).decode('ascii'), strict=False)
-  net = str(net)
-
-  if not net in bans or time.time() - bans[net]['last_attempt'] > RETRY_WINDOW:
-    bans[net] = { 'attempts': 0 }
-    active_window = RETRY_WINDOW
-  else:
-    active_window = time.time() - bans[net]['last_attempt']
-
-  bans[net]['attempts'] += 1
-  bans[net]['last_attempt'] = time.time()
-
-  active_window = time.time() - bans[net]['last_attempt']
-
-  if bans[net]['attempts'] >= MAX_ATTEMPTS:
-    log['time'] = int(round(time.time()))
-    log['priority'] = 'crit'
-    log['message'] = 'Banning %s' % net
-    r.lpush('F2B_LOG', json.dumps(log, ensure_ascii=False))
-    print 'Banning %s for %d minutes' % (net, BAN_TIME / 60)
-    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'])
-    r.hset('F2B_ACTIVE_BANS', '%s' % net, log['time'] + BAN_TIME)
-  else:
-    log['time'] = int(round(time.time()))
-    log['priority'] = 'warn'
-    log['message'] = '%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net)
-    r.lpush('F2B_LOG', json.dumps(log, ensure_ascii=False))
-    print '%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net)
-
-def unban(net):
-  log['time'] = int(round(time.time()))
-  log['priority'] = 'info'
-  r.lpush('F2B_LOG', json.dumps(log, ensure_ascii=False))
-  if not net in bans:
-    log['message'] = '%s is not banned, skipping unban and deleting from queue (if any)' % net
-    r.lpush('F2B_LOG', json.dumps(log, ensure_ascii=False))
-    print '%s is not banned, skipping unban and deleting from queue (if any)' % net
-    r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
-    return
-  log['message'] = 'Unbanning %s' % net
-  r.lpush('F2B_LOG', json.dumps(log, ensure_ascii=False))
-  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'])
-  r.hdel('F2B_ACTIVE_BANS', '%s' % net)
-  r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
-  del bans[net]
-
-def quit(signum, frame):
-  global quit_now
-  quit_now = True
-
-def clear():
-  log['time'] = int(round(time.time()))
-  log['priority'] = 'info'
-  log['message'] = 'Clearing all bans'
-  r.lpush('F2B_LOG', json.dumps(log, ensure_ascii=False))
-  print 'Clearing all bans'
-  for net in bans.copy():
-    unban(net)
-  pubsub.unsubscribe()
-
-def watch():
-  log['time'] = int(round(time.time()))
-  log['priority'] = 'info'
-  log['message'] = 'Watching Redis channel F2B_CHANNEL'
-  r.lpush('F2B_LOG', json.dumps(log, ensure_ascii=False))
-  pubsub.subscribe('F2B_CHANNEL')
-  print 'Subscribing to Redis channel F2B_CHANNEL'
-  while True:
-    for item in pubsub.listen():
-      for rule_id, rule_regex in RULES.iteritems():
-        if item['data'] and item['type'] == 'message':
-          result = re.search(rule_regex, item['data'])
-          if result:
-            addr = result.group(1)
-            ip = ipaddress.ip_address(addr.decode('ascii'))
-            if ip.is_private or ip.is_loopback:
-              continue
-            print '%s matched rule id %d' % (addr, rule_id)
-            log['time'] = int(round(time.time()))
-            log['priority'] = 'warn'
-            log['message'] = '%s matched rule id %d' % (addr, rule_id)
-            r.lpush('F2B_LOG', json.dumps(log, ensure_ascii=False))
-            ban(addr)
-
-def autopurge():
-  while not quit_now:
-    BAN_TIME = int(r.get('F2B_BAN_TIME'))
-    MAX_ATTEMPTS = int(r.get('F2B_MAX_ATTEMPTS'))
-    QUEUE_UNBAN = r.hgetall('F2B_QUEUE_UNBAN')
-    if QUEUE_UNBAN:
-      for net in QUEUE_UNBAN:
-        unban(str(net))
-    for net in bans.copy():
-      if bans[net]['attempts'] >= MAX_ATTEMPTS:
-        if time.time() - bans[net]['last_attempt'] > BAN_TIME:
-          unban(net)
-    time.sleep(10)
-
-if __name__ == '__main__':
-
-  watch_thread = Thread(target=watch)
-  watch_thread.daemon = True
-  watch_thread.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:
-    time.sleep(0.5)
+#!/usr/bin/env python2
+
+import re
+import os
+import time
+import atexit
+import signal
+import ipaddress
+import subprocess
+from threading import Thread
+import redis
+import time
+import json
+import iptc
+
+r = redis.StrictRedis(host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0)
+pubsub = r.pubsub()
+
+RULES = {}
+RULES[1] = 'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed'
+RULES[2] = '-login: Disconnected \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),'
+RULES[3] = '-login: Aborted login \(no auth .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
+RULES[4] = '-login: Aborted login \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
+RULES[5] = 'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked'
+RULES[6] = 'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)'
+
+if not r.get('F2B_OPTIONS'):
+  f2options['ban_time'] = int(r.get('F2B_BAN_TIME')) or 1800
+  f2options['max_attempts'] = int(r.get('F2B_MAX_ATTEMPTS')) or 10
+  f2options['retry_window'] = int(r.get('F2B_RETRY_WINDOW')) or 600
+  f2options['netban_ipv4'] = int(r.get('F2B_NETBAN_IPV4')) or 24
+  f2options['netban_ipv6'] = int(r.get('F2B_NETBAN_IPV6')) or 64
+  r.set('F2B_OPTIONS', json.dumps(f2options, ensure_ascii=False))
+else:
+  try:
+    f2options = json.loads(r.get('F2B_OPTIONS'))
+  except ValueError, e:
+    print 'Error loading F2B options: F2B_OPTIONS is not json'
+    raise SystemExit(1)
+
+if r.exists('F2B_LOG'):
+  r.rename('F2B_LOG', 'NETFILTER_LOG')
+
+bans = {}
+log = {}
+quit_now = False
+
+def ban(address):
+  BAN_TIME = int(f2options['ban_time'])
+  MAX_ATTEMPTS = int(f2options['max_attempts'])
+  RETRY_WINDOW = int(f2options['retry_window'])
+  NETBAN_IPV4 = '/' + str(f2options['netban_ipv4'])
+  NETBAN_IPV6 = '/' + str(f2options['netban_ipv6'])
+  WHITELIST = r.hgetall('F2B_WHITELIST')
+
+  ip = ipaddress.ip_address(address.decode('ascii'))
+  if type(ip) is ipaddress.IPv6Address and ip.ipv4_mapped:
+    ip = ip.ipv4_mapped
+    address = str(ip)
+  if ip.is_private or ip.is_loopback:
+    return
+
+  self_network = ipaddress.ip_network(address.decode('ascii'))
+  if WHITELIST:
+    for wl_key in WHITELIST:
+      wl_net = ipaddress.ip_network(wl_key.decode('ascii'), False)
+      if wl_net.overlaps(self_network):
+        log['time'] = int(round(time.time()))
+        log['priority'] = 'info'
+        log['message'] = 'Address %s is whitelisted by rule %s' % (self_network, wl_net)
+        r.lpush('NETFILTER_LOG', json.dumps(log, ensure_ascii=False))
+        print 'Address %s is whitelisted by rule %s' % (self_network, wl_net)
+        return
+
+  net = ipaddress.ip_network((address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)).decode('ascii'), strict=False)
+  net = str(net)
+
+  if not net in bans or time.time() - bans[net]['last_attempt'] > RETRY_WINDOW:
+    bans[net] = { 'attempts': 0 }
+    active_window = RETRY_WINDOW
+  else:
+    active_window = time.time() - bans[net]['last_attempt']
+
+  bans[net]['attempts'] += 1
+  bans[net]['last_attempt'] = time.time()
+
+  active_window = time.time() - bans[net]['last_attempt']
+
+  if bans[net]['attempts'] >= MAX_ATTEMPTS:
+    log['time'] = int(round(time.time()))
+    log['priority'] = 'crit'
+    log['message'] = 'Banning %s' % net
+    r.lpush('NETFILTER_LOG', json.dumps(log, ensure_ascii=False))
+    print 'Banning %s for %d minutes' % (net, BAN_TIME / 60)
+    if type(ip) is ipaddress.IPv4Address:
+      for c in ['INPUT', 'FORWARD']:
+        chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), c)
+        rule = iptc.Rule()
+        rule.src = net
+        target = iptc.Target(rule, "REJECT")
+        rule.target = target
+        if rule not in chain.rules:
+          chain.insert_rule(rule)
+    else:
+      for c in ['INPUT', 'FORWARD']:
+        chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), c)
+        rule = iptc.Rule6()
+        rule.src = net
+        target = iptc.Target(rule, "REJECT")
+        rule.target = target
+        if rule not in chain.rules:
+          chain.insert_rule(rule)
+    r.hset('F2B_ACTIVE_BANS', '%s' % net, log['time'] + BAN_TIME)
+  else:
+    log['time'] = int(round(time.time()))
+    log['priority'] = 'warn'
+    log['message'] = '%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net)
+    r.lpush('NETFILTER_LOG', json.dumps(log, ensure_ascii=False))
+    print '%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net)
+
+def unban(net):
+  log['time'] = int(round(time.time()))
+  log['priority'] = 'info'
+  r.lpush('NETFILTER_LOG', json.dumps(log, ensure_ascii=False))
+  #if not net in bans:
+  #  log['message'] = '%s is not banned, skipping unban and deleting from queue (if any)' % net
+  #  r.lpush('NETFILTER_LOG', json.dumps(log, ensure_ascii=False))
+  #  print '%s is not banned, skipping unban and deleting from queue (if any)' % net
+  #  r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
+  #  return
+  log['message'] = 'Unbanning %s' % net
+  r.lpush('NETFILTER_LOG', json.dumps(log, ensure_ascii=False))
+  print 'Unbanning %s' % net
+  if type(ipaddress.ip_network(net.decode('ascii'))) is ipaddress.IPv4Network:
+    for c in ['INPUT', 'FORWARD']:
+      chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), c)
+      rule = iptc.Rule()
+      rule.src = net
+      target = iptc.Target(rule, "REJECT")
+      rule.target = target
+      if rule in chain.rules:
+        chain.delete_rule(rule)
+  else:
+    for c in ['INPUT', 'FORWARD']:
+      chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), c)
+      rule = iptc.Rule6()
+      rule.src = net
+      target = iptc.Target(rule, "REJECT")
+      rule.target = target
+      if rule in chain.rules:
+        chain.delete_rule(rule)
+  r.hdel('F2B_ACTIVE_BANS', '%s' % net)
+  r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
+  if net in bans:
+    del bans[net]
+
+def quit(signum, frame):
+  global quit_now
+  quit_now = True
+
+def clear():
+  log['time'] = int(round(time.time()))
+  log['priority'] = 'info'
+  log['message'] = 'Clearing all bans'
+  r.lpush('NETFILTER_LOG', json.dumps(log, ensure_ascii=False))
+  print 'Clearing all bans'
+  for net in bans.copy():
+    unban(net)
+  pubsub.unsubscribe()
+
+def watch():
+  log['time'] = int(round(time.time()))
+  log['priority'] = 'info'
+  log['message'] = 'Watching Redis channel F2B_CHANNEL'
+  r.lpush('NETFILTER_LOG', json.dumps(log, ensure_ascii=False))
+  pubsub.subscribe('F2B_CHANNEL')
+  print 'Subscribing to Redis channel F2B_CHANNEL'
+  while True:
+    for item in pubsub.listen():
+      for rule_id, rule_regex in RULES.iteritems():
+        if item['data'] and item['type'] == 'message':
+          result = re.search(rule_regex, item['data'])
+          if result:
+            addr = result.group(1)
+            ip = ipaddress.ip_address(addr.decode('ascii'))
+            if ip.is_private or ip.is_loopback:
+              continue
+            print '%s matched rule id %d' % (addr, rule_id)
+            log['time'] = int(round(time.time()))
+            log['priority'] = 'warn'
+            log['message'] = '%s matched rule id %d' % (addr, rule_id)
+            r.lpush('NETFILTER_LOG', json.dumps(log, ensure_ascii=False))
+            ban(addr)
+
+def snat(snat_target):
+  def get_snat_rule():
+    rule = iptc.Rule()
+    rule.position = 1
+    rule.src = os.getenv('IPV4_NETWORK', '172.22.1') + '.0/24'
+    rule.dst = '!' + rule.src
+    target = rule.create_target("SNAT")
+    target.to_source = snat_target
+    return rule
+
+  while True:
+    table = iptc.Table('nat')
+    table.autocommit = False
+    chain = iptc.Chain(table, 'POSTROUTING')
+    if get_snat_rule() not in chain.rules:
+      log['time'] = int(round(time.time()))
+      log['priority'] = 'info'
+      log['message'] = 'Added POSTROUTING rule for source network ' + get_snat_rule().src + ' to SNAT target ' + snat_target
+      r.lpush('NETFILTER_LOG', json.dumps(log, ensure_ascii=False))
+      print log['message']
+      chain.insert_rule(get_snat_rule())
+      table.commit()
+      table.refresh()
+    time.sleep(10)
+
+def autopurge():
+  while not quit_now:
+    BAN_TIME = int(r.get('F2B_BAN_TIME'))
+    MAX_ATTEMPTS = int(r.get('F2B_MAX_ATTEMPTS'))
+    QUEUE_UNBAN = r.hgetall('F2B_QUEUE_UNBAN')
+    if QUEUE_UNBAN:
+      for net in QUEUE_UNBAN:
+        unban(str(net))
+    for net in bans.copy():
+      if bans[net]['attempts'] >= MAX_ATTEMPTS:
+        if time.time() - bans[net]['last_attempt'] > BAN_TIME:
+          unban(net)
+    time.sleep(10)
+
+def cleanPrevious():
+  print "Cleaning previously cached bans"
+  F2B_ACTIVE_BANS = r.hgetall('F2B_ACTIVE_BANS')
+  if F2B_ACTIVE_BANS:
+   for net in F2B_ACTIVE_BANS:
+     unban(str(net))
+
+if __name__ == '__main__':
+
+  cleanPrevious()
+
+  watch_thread = Thread(target=watch)
+  watch_thread.daemon = True
+  watch_thread.start()
+
+  if os.getenv('SNAT_TO_SOURCE'):
+    try:
+      snat_ip = os.getenv('SNAT_TO_SOURCE').decode('ascii')
+      snat_ipo = ipaddress.ip_address(snat_ip)
+      if type(snat_ipo) is ipaddress.IPv4Address:
+        snat_thread = Thread(target=snat,args=(snat_ip,))
+        snat_thread.daemon = True
+        snat_thread.start()
+    except ValueError:
+      print os.getenv('SNAT_TO_SOURCE') + ' is not a valid IPv4 address'
+
+  autopurge_thread = Thread(target=autopurge)
+  autopurge_thread.daemon = True
+  autopurge_thread.start()
+
+  signal.signal(signal.SIGTERM, quit)
+  atexit.register(clear)
+
+  while not quit_now:
+    time.sleep(0.5)