blob: 1adcf599cae6f7418fd2f815bf473ef42af24c0d [file] [log] [blame]
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +02001#!/usr/bin/env python3
2
3import re
4import os
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +02005import sys
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +02006import time
7import atexit
8import signal
9import ipaddress
10from collections import Counter
11from random import randint
12from threading import Thread
13from threading import Lock
14import redis
15import json
16import iptc
17import dns.resolver
18import dns.exception
19
20while True:
21 try:
22 redis_slaveof_ip = os.getenv('REDIS_SLAVEOF_IP', '')
23 redis_slaveof_port = os.getenv('REDIS_SLAVEOF_PORT', '')
24 if "".__eq__(redis_slaveof_ip):
25 r = redis.StrictRedis(host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0)
26 else:
27 r = redis.StrictRedis(host=redis_slaveof_ip, decode_responses=True, port=redis_slaveof_port, db=0)
28 r.ping()
29 except Exception as ex:
30 print('%s - trying again in 3 seconds' % (ex))
31 time.sleep(3)
32 else:
33 break
34
35pubsub = r.pubsub()
36
37WHITELIST = []
38BLACKLIST= []
39
40bans = {}
41
42quit_now = False
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020043exit_code = 0
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +020044lock = Lock()
45
46def log(priority, message):
47 tolog = {}
48 tolog['time'] = int(round(time.time()))
49 tolog['priority'] = priority
50 tolog['message'] = message
51 r.lpush('NETFILTER_LOG', json.dumps(tolog, ensure_ascii=False))
52 print(message)
53
54def logWarn(message):
55 log('warn', message)
56
57def logCrit(message):
58 log('crit', message)
59
60def logInfo(message):
61 log('info', message)
62
63def refreshF2boptions():
64 global f2boptions
65 global quit_now
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020066 global exit_code
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +020067 if not r.get('F2B_OPTIONS'):
68 f2boptions = {}
69 f2boptions['ban_time'] = int
70 f2boptions['max_attempts'] = int
71 f2boptions['retry_window'] = int
72 f2boptions['netban_ipv4'] = int
73 f2boptions['netban_ipv6'] = int
74 f2boptions['ban_time'] = r.get('F2B_BAN_TIME') or 1800
75 f2boptions['max_attempts'] = r.get('F2B_MAX_ATTEMPTS') or 10
76 f2boptions['retry_window'] = r.get('F2B_RETRY_WINDOW') or 600
77 f2boptions['netban_ipv4'] = r.get('F2B_NETBAN_IPV4') or 32
78 f2boptions['netban_ipv6'] = r.get('F2B_NETBAN_IPV6') or 128
79 r.set('F2B_OPTIONS', json.dumps(f2boptions, ensure_ascii=False))
80 else:
81 try:
82 f2boptions = {}
83 f2boptions = json.loads(r.get('F2B_OPTIONS'))
84 except ValueError:
85 print('Error loading F2B options: F2B_OPTIONS is not json')
86 quit_now = True
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020087 exit_code = 2
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +020088
89def refreshF2bregex():
90 global f2bregex
91 global quit_now
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020092 global exit_code
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +020093 if not r.get('F2B_REGEX'):
94 f2bregex = {}
Matthias Andreas Benkard12a57352021-12-28 18:02:04 +010095 f2bregex[1] = 'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)'
96 f2bregex[2] = 'Rspamd UI: Invalid password by ([0-9a-f\.:]+)'
97 f2bregex[3] = 'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed'
98 f2bregex[4] = 'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+'
99 f2bregex[5] = 'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+'
100 f2bregex[6] = '-login: Disconnected \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),'
101 f2bregex[7] = '-login: Aborted login \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
102 f2bregex[8] = '-login: Aborted login \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
103 f2bregex[9] = 'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked'
104 f2bregex[10] = '([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+'
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200105 r.set('F2B_REGEX', json.dumps(f2bregex, ensure_ascii=False))
106 else:
107 try:
108 f2bregex = {}
109 f2bregex = json.loads(r.get('F2B_REGEX'))
110 except ValueError:
111 print('Error loading F2B options: F2B_REGEX is not json')
112 quit_now = True
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200113 exit_code = 2
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200114
115if r.exists('F2B_LOG'):
116 r.rename('F2B_LOG', 'NETFILTER_LOG')
117
118def mailcowChainOrder():
119 global lock
120 global quit_now
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200121 global exit_code
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200122 while not quit_now:
123 time.sleep(10)
124 with lock:
125 filter4_table = iptc.Table(iptc.Table.FILTER)
126 filter4_table.refresh()
Matthias Andreas Benkarda9e47d22021-12-28 18:06:33 +0100127 for f in [filter4_table]:
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200128 forward_chain = iptc.Chain(f, 'FORWARD')
129 input_chain = iptc.Chain(f, 'INPUT')
130 for chain in [forward_chain, input_chain]:
131 target_found = False
132 for position, item in enumerate(chain.rules):
133 if item.target.name == 'MAILCOW':
134 target_found = True
135 if position > 2:
136 logCrit('Error in %s chain order: MAILCOW on position %d, restarting container' % (chain.name, position))
137 quit_now = True
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200138 exit_code = 2
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200139 if not target_found:
140 logCrit('Error in %s chain: MAILCOW target not found, restarting container' % (chain.name))
141 quit_now = True
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200142 exit_code = 2
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200143
144def ban(address):
145 global lock
146 refreshF2boptions()
147 BAN_TIME = int(f2boptions['ban_time'])
148 MAX_ATTEMPTS = int(f2boptions['max_attempts'])
149 RETRY_WINDOW = int(f2boptions['retry_window'])
150 NETBAN_IPV4 = '/' + str(f2boptions['netban_ipv4'])
151 NETBAN_IPV6 = '/' + str(f2boptions['netban_ipv6'])
152
153 ip = ipaddress.ip_address(address)
154 if type(ip) is ipaddress.IPv6Address and ip.ipv4_mapped:
155 ip = ip.ipv4_mapped
156 address = str(ip)
157 if ip.is_private or ip.is_loopback:
158 return
159
160 self_network = ipaddress.ip_network(address)
161
162 with lock:
163 temp_whitelist = set(WHITELIST)
164
165 if temp_whitelist:
166 for wl_key in temp_whitelist:
167 wl_net = ipaddress.ip_network(wl_key, False)
168 if wl_net.overlaps(self_network):
169 logInfo('Address %s is whitelisted by rule %s' % (self_network, wl_net))
170 return
171
172 net = ipaddress.ip_network((address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False)
173 net = str(net)
174
175 if not net in bans or time.time() - bans[net]['last_attempt'] > RETRY_WINDOW:
176 bans[net] = { 'attempts': 0 }
177 active_window = RETRY_WINDOW
178 else:
179 active_window = time.time() - bans[net]['last_attempt']
180
181 bans[net]['attempts'] += 1
182 bans[net]['last_attempt'] = time.time()
183
184 active_window = time.time() - bans[net]['last_attempt']
185
186 if bans[net]['attempts'] >= MAX_ATTEMPTS:
187 cur_time = int(round(time.time()))
188 logCrit('Banning %s for %d minutes' % (net, BAN_TIME / 60))
189 if type(ip) is ipaddress.IPv4Address:
190 with lock:
191 chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), 'MAILCOW')
192 rule = iptc.Rule()
193 rule.src = net
194 target = iptc.Target(rule, "REJECT")
195 rule.target = target
196 if rule not in chain.rules:
197 chain.insert_rule(rule)
198 else:
Matthias Andreas Benkarda9e47d22021-12-28 18:06:33 +0100199 pass
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200200 r.hset('F2B_ACTIVE_BANS', '%s' % net, cur_time + BAN_TIME)
201 else:
202 logWarn('%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net))
203
204def unban(net):
205 global lock
206 if not net in bans:
207 logInfo('%s is not banned, skipping unban and deleting from queue (if any)' % net)
208 r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
209 return
210 logInfo('Unbanning %s' % net)
211 if type(ipaddress.ip_network(net)) is ipaddress.IPv4Network:
212 with lock:
213 chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), 'MAILCOW')
214 rule = iptc.Rule()
215 rule.src = net
216 target = iptc.Target(rule, "REJECT")
217 rule.target = target
218 if rule in chain.rules:
219 chain.delete_rule(rule)
220 else:
Matthias Andreas Benkarda9e47d22021-12-28 18:06:33 +0100221 pass
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200222 r.hdel('F2B_ACTIVE_BANS', '%s' % net)
223 r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
224 if net in bans:
225 del bans[net]
226
227def permBan(net, unban=False):
228 global lock
229 if type(ipaddress.ip_network(net, strict=False)) is ipaddress.IPv4Network:
230 with lock:
231 chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), 'MAILCOW')
232 rule = iptc.Rule()
233 rule.src = net
234 target = iptc.Target(rule, "REJECT")
235 rule.target = target
236 if rule not in chain.rules and not unban:
237 logCrit('Add host/network %s to blacklist' % net)
238 chain.insert_rule(rule)
Matthias Andreas Benkard12a57352021-12-28 18:02:04 +0100239 r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200240 elif rule in chain.rules and unban:
241 logCrit('Remove host/network %s from blacklist' % net)
242 chain.delete_rule(rule)
243 r.hdel('F2B_PERM_BANS', '%s' % net)
244 else:
Matthias Andreas Benkarda9e47d22021-12-28 18:06:33 +0100245 pass
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200246
247def quit(signum, frame):
248 global quit_now
249 quit_now = True
250
251def clear():
252 global lock
253 logInfo('Clearing all bans')
254 for net in bans.copy():
255 unban(net)
256 with lock:
257 filter4_table = iptc.Table(iptc.Table.FILTER)
Matthias Andreas Benkarda9e47d22021-12-28 18:06:33 +0100258 for filter_table in [filter4_table]:
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200259 filter_table.autocommit = False
260 forward_chain = iptc.Chain(filter_table, "FORWARD")
261 input_chain = iptc.Chain(filter_table, "INPUT")
262 mailcow_chain = iptc.Chain(filter_table, "MAILCOW")
263 if mailcow_chain in filter_table.chains:
264 for rule in mailcow_chain.rules:
265 mailcow_chain.delete_rule(rule)
266 for rule in forward_chain.rules:
267 if rule.target.name == 'MAILCOW':
268 forward_chain.delete_rule(rule)
269 for rule in input_chain.rules:
270 if rule.target.name == 'MAILCOW':
271 input_chain.delete_rule(rule)
272 filter_table.delete_chain("MAILCOW")
273 filter_table.commit()
274 filter_table.refresh()
275 filter_table.autocommit = True
276 r.delete('F2B_ACTIVE_BANS')
277 r.delete('F2B_PERM_BANS')
278 pubsub.unsubscribe()
279
280def watch():
281 logInfo('Watching Redis channel F2B_CHANNEL')
282 pubsub.subscribe('F2B_CHANNEL')
283
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200284 global quit_now
285 global exit_code
286
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200287 while not quit_now:
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200288 try:
289 for item in pubsub.listen():
290 refreshF2bregex()
291 for rule_id, rule_regex in f2bregex.items():
292 if item['data'] and item['type'] == 'message':
293 try:
294 result = re.search(rule_regex, item['data'])
295 except re.error:
296 result = False
297 if result:
298 addr = result.group(1)
299 ip = ipaddress.ip_address(addr)
300 if ip.is_private or ip.is_loopback:
301 continue
302 logWarn('%s matched rule id %s (%s)' % (addr, rule_id, item['data']))
303 ban(addr)
304 except Exception as ex:
305 logWarn('Error reading log line from pubsub')
306 quit_now = True
307 exit_code = 2
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200308
309def snat4(snat_target):
310 global lock
311 global quit_now
312
313 def get_snat4_rule():
314 rule = iptc.Rule()
315 rule.src = os.getenv('IPV4_NETWORK', '172.22.1') + '.0/24'
316 rule.dst = '!' + rule.src
317 target = rule.create_target("SNAT")
318 target.to_source = snat_target
319 return rule
320
321 while not quit_now:
322 time.sleep(10)
323 with lock:
324 try:
325 table = iptc.Table('nat')
326 table.refresh()
327 chain = iptc.Chain(table, 'POSTROUTING')
328 table.autocommit = False
329 if get_snat4_rule() not in chain.rules:
330 logCrit('Added POSTROUTING rule for source network %s to SNAT target %s' % (get_snat4_rule().src, snat_target))
331 chain.insert_rule(get_snat4_rule())
332 table.commit()
333 else:
334 for position, item in enumerate(chain.rules):
335 if item == get_snat4_rule():
336 if position != 0:
337 chain.delete_rule(get_snat4_rule())
338 table.commit()
339 table.autocommit = True
340 except:
Matthias Andreas Benkarda9e47d22021-12-28 18:06:33 +0100341 print('Error running SNAT4, retrying...')
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200342
343def autopurge():
344 while not quit_now:
345 time.sleep(10)
346 refreshF2boptions()
347 BAN_TIME = int(f2boptions['ban_time'])
348 MAX_ATTEMPTS = int(f2boptions['max_attempts'])
349 QUEUE_UNBAN = r.hgetall('F2B_QUEUE_UNBAN')
350 if QUEUE_UNBAN:
351 for net in QUEUE_UNBAN:
352 unban(str(net))
353 for net in bans.copy():
354 if bans[net]['attempts'] >= MAX_ATTEMPTS:
355 if time.time() - bans[net]['last_attempt'] > BAN_TIME:
356 unban(net)
357
358def isIpNetwork(address):
359 try:
360 ipaddress.ip_network(address, False)
361 except ValueError:
362 return False
363 return True
364
365
366def genNetworkList(list):
367 resolver = dns.resolver.Resolver()
368 hostnames = []
369 networks = []
370 for key in list:
371 if isIpNetwork(key):
372 networks.append(key)
373 else:
374 hostnames.append(key)
375 for hostname in hostnames:
376 hostname_ips = []
377 for rdtype in ['A', 'AAAA']:
378 try:
379 answer = resolver.resolve(qname=hostname, rdtype=rdtype, lifetime=3)
380 except dns.exception.Timeout:
381 logInfo('Hostname %s timedout on resolve' % hostname)
382 break
383 except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
384 continue
385 except dns.exception.DNSException as dnsexception:
386 logInfo('%s' % dnsexception)
387 continue
388 for rdata in answer:
389 hostname_ips.append(rdata.to_text())
390 networks.extend(hostname_ips)
391 return set(networks)
392
393def whitelistUpdate():
394 global lock
395 global quit_now
396 global WHITELIST
397 while not quit_now:
398 start_time = time.time()
399 list = r.hgetall('F2B_WHITELIST')
400 new_whitelist = []
401 if list:
402 new_whitelist = genNetworkList(list)
403 with lock:
404 if Counter(new_whitelist) != Counter(WHITELIST):
405 WHITELIST = new_whitelist
406 logInfo('Whitelist was changed, it has %s entries' % len(WHITELIST))
Matthias Andreas Benkard12a57352021-12-28 18:02:04 +0100407 time.sleep(60.0 - ((time.time() - start_time) % 60.0))
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200408
409def blacklistUpdate():
410 global quit_now
411 global BLACKLIST
412 while not quit_now:
413 start_time = time.time()
414 list = r.hgetall('F2B_BLACKLIST')
415 new_blacklist = []
416 if list:
417 new_blacklist = genNetworkList(list)
Matthias Andreas Benkard12a57352021-12-28 18:02:04 +0100418 if Counter(new_blacklist) != Counter(BLACKLIST):
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200419 addban = set(new_blacklist).difference(BLACKLIST)
420 delban = set(BLACKLIST).difference(new_blacklist)
421 BLACKLIST = new_blacklist
422 logInfo('Blacklist was changed, it has %s entries' % len(BLACKLIST))
423 if addban:
424 for net in addban:
425 permBan(net=net)
426 if delban:
427 for net in delban:
428 permBan(net=net, unban=True)
Matthias Andreas Benkard12a57352021-12-28 18:02:04 +0100429 time.sleep(60.0 - ((time.time() - start_time) % 60.0))
Matthias Andreas Benkard8bb60d02021-08-16 10:41:15 +0200430
431def initChain():
432 # Is called before threads start, no locking
433 print("Initializing mailcow netfilter chain")
434 # IPv4
435 if not iptc.Chain(iptc.Table(iptc.Table.FILTER), "MAILCOW") in iptc.Table(iptc.Table.FILTER).chains:
436 iptc.Table(iptc.Table.FILTER).create_chain("MAILCOW")
437 for c in ['FORWARD', 'INPUT']:
438 chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), c)
439 rule = iptc.Rule()
440 rule.src = '0.0.0.0/0'
441 rule.dst = '0.0.0.0/0'
442 target = iptc.Target(rule, "MAILCOW")
443 rule.target = target
444 if rule not in chain.rules:
445 chain.insert_rule(rule)
446
447if __name__ == '__main__':
448
449 # In case a previous session was killed without cleanup
450 clear()
451 # Reinit MAILCOW chain
452 initChain()
453
454 watch_thread = Thread(target=watch)
455 watch_thread.daemon = True
456 watch_thread.start()
457
458 if os.getenv('SNAT_TO_SOURCE') and os.getenv('SNAT_TO_SOURCE') != 'n':
459 try:
460 snat_ip = os.getenv('SNAT_TO_SOURCE')
461 snat_ipo = ipaddress.ip_address(snat_ip)
462 if type(snat_ipo) is ipaddress.IPv4Address:
463 snat4_thread = Thread(target=snat4,args=(snat_ip,))
464 snat4_thread.daemon = True
465 snat4_thread.start()
466 except ValueError:
467 print(os.getenv('SNAT_TO_SOURCE') + ' is not a valid IPv4 address')
468
469 autopurge_thread = Thread(target=autopurge)
470 autopurge_thread.daemon = True
471 autopurge_thread.start()
472
473 mailcowchainwatch_thread = Thread(target=mailcowChainOrder)
474 mailcowchainwatch_thread.daemon = True
475 mailcowchainwatch_thread.start()
476
477 blacklistupdate_thread = Thread(target=blacklistUpdate)
478 blacklistupdate_thread.daemon = True
479 blacklistupdate_thread.start()
480
481 whitelistupdate_thread = Thread(target=whitelistUpdate)
482 whitelistupdate_thread.daemon = True
483 whitelistupdate_thread.start()
484
485 signal.signal(signal.SIGTERM, quit)
486 atexit.register(clear)
487
488 while not quit_now:
489 time.sleep(0.5)
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200490
491 sys.exit(exit_code)