| #!/usr/bin/env python |
| # -*- coding: utf-8 -*- |
| # Licensed to the Apache Software Foundation (ASF) under one or more |
| # contributor license agreements. See the NOTICE file distributed with |
| # this work for additional information regarding copyright ownership. |
| # The ASF licenses this file to You under the Apache License, Version 2.0 |
| # (the "License"); you may not use this file except in compliance with |
| # the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| import os |
| import subprocess |
| import re |
| import json |
| import requests |
| import netaddr |
| import asfpy.daemon |
| import yaml |
| import socket |
| import time |
| import sys |
| import argparse |
| import syslog |
| |
| DEBUG = False |
| CONFIG = None |
| SYSLOG = None |
| MAX_IPTABLES_TRIES = 10 |
| IPTABLES_EXEC = '/sbin/iptables' |
| IP6TABLES_EXEC = '/sbin/ip6tables' |
| LAST_UPLOAD = 0 |
| |
| def getbans(chain = 'INPUT'): |
| """ Gets a list of all bans in a chain """ |
| banlist = [] |
| |
| # Get IPv4 list |
| for i in range(0,MAX_IPTABLES_TRIES): |
| try: |
| out = subprocess.check_output([IPTABLES_EXEC, '--list', chain, '-n', '--line-numbers'], stderr = subprocess.STDOUT) |
| except subprocess.CalledProcessError as err: |
| if 'you must be root' in err.output: |
| print("Looks like blocky doesn't have permission to access iptables, giving up completely! (are you running as root?)") |
| sys.exit(-1) |
| time.sleep(1) # write lock, probably |
| if out: |
| for line in out.split("\n"): |
| m = re.match(r"^(\d+)\s+([A-Z]+)\s+(all|tcp|udp)\s+(\S+)\s+([0-9a-f.:/]+)\s+([0-9a-f.:/]+)\s*(.*?)$", line) |
| if m: |
| ln = m.group(1) |
| action = m.group(2) |
| protocol = m.group(3) |
| option = m.group(4) |
| source = m.group(5) |
| destination = m.group(6) |
| extensions = m.group(7) |
| |
| entry = { |
| 'chain': chain, |
| 'linenumber': ln, |
| 'action': action, |
| 'protocol': protocol, |
| 'option': option, |
| 'source': source, |
| 'destination': destination, |
| 'extensions': extensions, |
| } |
| |
| banlist.append(entry) |
| break |
| # Get IPv6 list |
| if not os.path.exists(IP6TABLES_EXEC): |
| return banlist |
| for i in range(0,MAX_IPTABLES_TRIES): |
| try: |
| out = subprocess.check_output([IP6TABLES_EXEC, '--list', chain, '-n', '--line-numbers'], stderr = subprocess.STDOUT) |
| except subprocess.CalledProcessError as err: |
| if 'you must be root' in err.output: |
| print("Looks like blocky doesn't have permission to access iptables, giving up completely! (are you running as root?)") |
| sys.exit(-1) |
| time.sleep(1) # write lock, probably |
| if out: |
| for line in out.split("\n"): |
| # Unlike ipv4 iptables, the 'option' thing is blank here, so omit it |
| m = re.match(r"^(\d+)\s+([A-Z]+)\s+(all|tcp|udp)\s+([0-9a-f.:/]+)\s+([0-9a-f.:/]+)\s*(.*?)$", line) |
| if m: |
| ln = m.group(1) |
| action = m.group(2) |
| protocol = m.group(3) |
| source = m.group(4) |
| destination = m.group(5) |
| extensions = m.group(6) |
| |
| entry = { |
| 'chain': chain, |
| 'linenumber': ln, |
| 'action': action, |
| 'protocol': protocol, |
| 'option': '---', |
| 'source': source, |
| 'destination': destination, |
| 'extensions': extensions, |
| } |
| |
| banlist.append(entry) |
| break |
| return banlist |
| |
| def iptables(ip, action): |
| """ Runs an iptables action on an IP (-A, -C or -D), returns true if |
| succeeded, false otherwise """ |
| try: |
| exe = IPTABLES_EXEC |
| if ':' in ip: |
| exe = IP6TABLES_EXEC |
| subprocess.check_call([ |
| exe, |
| action, "INPUT", |
| "-s", ip, |
| "-j", "DROP", |
| "-m", "comment", |
| "--comment", |
| "Banned by Blocky/2.0" |
| ], stderr=open(os.devnull, 'wb')) |
| except subprocess.CalledProcessError as err: # iptables error, expected result variant |
| return False |
| except OSError as err: |
| print("%s not found or inaccessible: %s" % (exe, err)) |
| return False |
| return True |
| |
| |
| def ban(ip): |
| """ Bans an IP or CIDR block generically """ |
| if iptables(ip, '-A'): |
| return True |
| return False |
| |
| def unban_line(ip, linenumber, chain = 'INPUT'): |
| """ Unbans an IP or block by line number """ |
| if not linenumber: |
| return |
| exe = IPTABLES_EXEC |
| if ':' in ip: |
| exe = IP6TABLES_EXEC |
| if DEBUG: |
| print("Would have removed line %s from %s chain in iptables here..." % (linenumber, chain)) |
| return True |
| try: |
| subprocess.check_call([ |
| exe, |
| '-D', chain, linenumber |
| ], stderr=open(os.devnull, 'wb')) |
| except subprocess.CalledProcessError as err: # iptables error, expected result variant |
| return False |
| except OSError as err: |
| print("%s not found or inaccessible: %s" % (exe, err)) |
| return False |
| return True |
| |
| def inlist(banlist, ip): |
| """ Check if an IP or CIDR is listed in iptables, |
| either by itself or contained within a block (or the reverse) """ |
| lines = [] |
| if '/0' in ip: # DO NOT WANT |
| return lines |
| # First, check verbatim |
| for entry in banlist: |
| if entry['source'] == ip: |
| lines.append(entry) |
| # Check if block, then check for matches within |
| if '/' in ip: |
| me = netaddr.IPNetwork(ip) |
| for entry in banlist: |
| source = entry['source'] |
| if '/' not in source: # We don't want to do block vs block just yet |
| them = netaddr.IPAddress(source) |
| if them in me: |
| lines.append(entry) |
| |
| # Then the reverse; IP found within blocks? |
| else: |
| me = netaddr.IPAddress(ip) |
| for entry in banlist: |
| if '/' in entry['source'] and '/0' not in entry['source']: # blocks, but not /0 |
| them = netaddr.IPNetwork(entry['source']) |
| if me in them: |
| lines.append(entry) |
| return lines |
| |
| def note_ban(me, entry): |
| apiurl = "%s/note" % CONFIG['server']['apiurl'] |
| try: |
| requests.post(apiurl, json = { |
| 'hostname': me, |
| 'action': 'ban', |
| 'ip': entry['source'], |
| 'reason': entry.get('reason', "No reason specified") |
| }) |
| except request.RequestException: |
| pass # If it fails with a http error, it fails - we'll continue anyway |
| # Not sure if we should even syslog that.. |
| |
| def note_unban(me, entry): |
| apiurl = "%s/note" % CONFIG['server']['apiurl'] |
| try: |
| requests.post(apiurl, json = { |
| 'hostname': me, |
| 'action': 'unban', |
| 'ip': entry['source'], |
| 'reason': entry.get('reason', "No reason specified") |
| }) |
| except requests.RequestException: |
| pass # If it fails, it fails - we'll continue anyway |
| # Not sure if we should even syslog that.. |
| |
| def run_legacy_checks(): |
| """ Runs checks using the legacy blocky UI server (mod_lua) """ |
| apiurl = CONFIG['server']['legacyurl'] |
| actions = [] |
| mylist = getbans() |
| try: |
| actions = requests.get(apiurl).json() |
| syslog.syslog(syslog.LOG_INFO, "Fetched a total of %u firewall actions from %s" % (len(actions), apiurl)) |
| except: |
| syslog.syslog(syslog.LOG_WARNING, "Could not retrieve blocky actions list from %s - server down??!" % apiurl) |
| |
| whitelist = [] # Things we are unbanning, and thus shouldn't just ban right again |
| |
| # For each action element, find out what to do, and who to do it to. |
| for action in actions: |
| |
| # Unban request |
| target = action.get('target', '*') |
| if 'unban' in action: |
| if target == '*' or target == CONFIG['client']['hostname']: |
| ip = action.get('ip') |
| if ip: |
| ip = ip.strip() |
| block = None |
| if '/' in ip: |
| block = netaddr.IPNetwork(ip) |
| else: |
| if ':' in ip: |
| block = netaddr.IPNetwork("%s/128" % ip) # IPv6 |
| else: |
| block = netaddr.IPNetwork("%s/32" % ip) # IPv4 |
| whitelist.append(block) |
| found = inlist(mylist, ip) |
| if found: |
| entry = found[0] |
| syslog.syslog(syslog.LOG_INFO, "Removing %s from block list (found at line %s as %s)" % (ip, entry['linenumber'], entry['source'])) |
| if not unban_line(ip, found[0]['linenumber']): |
| syslog.syslog(syslog.LOG_WARNING, "Could not remove ban for %s from iptables!" % ip) |
| else: |
| mylist = getbans() # Refresh after action succeeded |
| |
| # Ban request? |
| elif 'ip' in action: |
| if target == '*' or target == CONFIG['client']['hostname']: |
| ip = action.get('ip') |
| if ip: |
| ip = ip.strip() # backwards compat |
| banit = True |
| block = None |
| if '/' in ip: |
| block = netaddr.IPNetwork(ip) |
| else: |
| if ':' in ip: |
| block = netaddr.IPNetwork("%s/128" % ip) # IPv6 |
| else: |
| block = netaddr.IPNetwork("%s/32" % ip) # IPv4 |
| for wblock in whitelist: |
| if block in wblock or wblock in block: |
| syslog.syslog(syslog.LOG_WARNING, "%s was requested banned but %s is whitelisted, ignoring ban" % (block, wblock)) |
| banit = False |
| if banit: |
| found = inlist(mylist, ip) |
| if not found: |
| reason = action.get('reason', "No reason specified") |
| syslog.syslog(syslog.LOG_INFO, "Adding %s to block list; %s" % (ip, reason)) |
| if not ban(ip): |
| syslog.syslog(syslog.LOG_WARNING, "Could not add ban for %s in iptables!" % ip) |
| else: |
| mylist = getbans() # Refresh after action succeeded |
| |
| def run_new_checks(): |
| """ Runs the blocky process using the modern UI server """ |
| global LAST_UPLOAD |
| |
| # First, get our rules and post 'em to the server, if need be |
| mylist = getbans() |
| if LAST_UPLOAD < (time.time() - 600): # Only send once every ten minutes |
| try: |
| rv = None |
| js = { |
| 'hostname': CONFIG['client']['hostname'], |
| 'iptables': mylist |
| } |
| apiurl = "%s/myrules" % CONFIG['server']['apiurl'] |
| rv = requests.put(apiurl, json = js) |
| assert(rv.status_code == 200) |
| LAST_UPLOAD = time.time() |
| except requests.RequestException: |
| if rv: |
| syslog.syslog(syslog.LOG_WARNING, rv.text) |
| syslog.syslog(syslog.LOG_WARNING, "Could not send my iptables list to server at %s - server down?" % apiurl) |
| |
| # Then, get applicable actions from the server |
| whitelist = [] |
| whiteblocks = [] # same as above, but as IPNetwork classes |
| banlist = [] |
| try: |
| whiteurl = "%s/whitelist" % CONFIG['server']['apiurl'] |
| whitelist = requests.get(whiteurl).json()['whitelist'] |
| except requests.RequestException: |
| syslog.syslog(syslog.LOG_WARNING, "Could not fetch whitelist entries at %s - server down?" % whiteurl) |
| try: |
| banurl = "%s/bans" % CONFIG['server']['apiurl'] |
| banlist = requests.get(banurl).json()['bans'] |
| except requests.RequestException: |
| syslog.syslog(syslog.LOG_WARNING, "Could not fetch whitelist entries at %s - server down?" % banurl) |
| |
| # First, check if we've banned someone on the whitelist |
| for entry in whitelist: |
| ip = entry.get('ip') |
| reason = entry.get('reason', 'No reason specified') |
| target = entry.get('target', '*') |
| if target == '*' or target == CONFIG['client']['hostname']: |
| if ip: |
| block = None |
| if '/' in ip: |
| block = netaddr.IPNetwork(ip) |
| else: |
| if ':' in ip: |
| block = netaddr.IPNetwork("%s/128" % ip) # IPv6 |
| else: |
| block = netaddr.IPNetwork("%s/32" % ip) # IPv4 |
| whiteblocks.append(block) |
| found = inlist(mylist, ip) |
| if found: |
| entry = found[0] |
| syslog.syslog(syslog.LOG_INFO, "Removing %s from block list (found at line %s as %s)" % (ip, entry['linenumber'], entry['source'])) |
| if not unban_line(ip, found[0]['linenumber']): |
| syslog.syslog(syslog.LOG_WARNING, "Could not remove ban for %s from iptables!" % ip) |
| else: |
| note_unban(CONFIG['client']['hostname'], found[0]['linenumber']) |
| mylist = getbans() # Refresh after action succeeded |
| |
| # Then process bans |
| for entry in banlist: |
| ip = entry.get('ip') |
| reason = entry.get('reason', 'No reason specified') |
| target = entry.get('target', '*') |
| if ip: |
| if target == '*' or target == CONFIG['client']['hostname']: |
| banit = True |
| block = None |
| if '/' in ip: |
| block = netaddr.IPNetwork(ip) |
| else: |
| if ':' in ip: |
| block = netaddr.IPNetwork("%s/128" % ip) # IPv6 |
| else: |
| block = netaddr.IPNetwork("%s/32" % ip) # IPv4 |
| for wblock in whiteblocks: |
| if block in wblock or wblock in block: |
| syslog.syslog(syslog.LOG_WARNING, "%s was requested banned but %s is whitelisted, ignoring ban" % (block, wblock)) |
| banit = False |
| if banit: |
| found = inlist(mylist, ip) |
| if not found: |
| reason = entry.get('reason', "No reason specified") |
| syslog.syslog(syslog.LOG_INFO, "Adding %s to block list; %s" % (ip, reason)) |
| if not ban(ip): |
| syslog.syslog(syslog.LOG_WARNING, "Could not add ban for %s in iptables!" % ip) |
| else: |
| mylist = getbans() # Refresh after action succeeded |
| found = inlist(mylist, ip) |
| if found: # make sure we have it in iptables now |
| note_ban(CONFIG['client']['hostname'], found[0]) |
| # All done for this time! |
| |
| def psyslog(a,b): |
| """ nasty hack for copying syslog calls to stdout """ |
| SYSLOG(a, b) |
| print("- " + b) |
| |
| def run_daemon(stdout = False): |
| global SYSLOG, CONFIG |
| if stdout: |
| SYSLOG = syslog.syslog |
| syslog.syslog = psyslog |
| else: |
| syslog.openlog('blocky', logoption=syslog.LOG_PID, facility=syslog.LOG_LOCAL0) |
| syslog.syslog(syslog.LOG_INFO, "Blocky/2 started") |
| while True: |
| # Fetch actions list - legacy or new |
| if CONFIG['server'].get('legacyurl'): |
| syslog.syslog(syslog.LOG_INFO, "Using legacy server component at %s" % CONFIG['server']['legacyurl']) |
| run_legacy_checks() |
| elif CONFIG['server'].get('apiurl'): |
| syslog.syslog(syslog.LOG_INFO, "Using modern server component at %s" % CONFIG['server']['apiurl']) |
| run_new_checks() |
| if stdout: |
| return |
| time.sleep(CONFIG['client'].get('interval', 60)) |
| |
| def base_parser(): |
| arg_parser = argparse.ArgumentParser() |
| arg_parser.add_argument("-x", "--user", help="not used (legacy compat)") |
| arg_parser.add_argument("-y", "--group", help="not used (legacy compat)") |
| arg_parser.add_argument("-u", "--unban", help="An IP or CIDR block to unban manually") |
| arg_parser.add_argument("-b", "--ban", help="An IP or CIDR block to ban manually") |
| arg_parser.add_argument("-d", "--daemonize", action = 'store_true', help="Run blocky as a daemon") |
| arg_parser.add_argument("-s", "--stop", action = 'store_true', help="Stop blocky daemon") |
| arg_parser.add_argument("-f", "--foreground", action = 'store_true', help="Run blocky in the foreground (debugging)") |
| return arg_parser |
| |
| |
| def start_client(): |
| global CONFIG |
| # Figure out who we are |
| me = socket.gethostname() |
| if 'apache.org' not in me: |
| me += '.apache.org' |
| |
| # Load YAML |
| CONFIG = yaml.load(open('./blocky.yaml').read()) |
| if 'client' not in CONFIG: |
| CONFIG['client'] = {} |
| if 'hostname' not in CONFIG['client']: |
| CONFIG['client']['hostname'] = me |
| |
| # Get current list of bans in iptables, upload it to blocky server |
| l = getbans() |
| |
| args = base_parser().parse_args() |
| |
| # CLI unban? |
| if args.unban: |
| ip = args.unban |
| found = inlist(l, ip) # random test |
| if found: |
| entry = found[0] # Only get the first entry, line numbers will then change ;\ |
| print("Found a block for %s on line %s in the %s chain (as %s), removing..." % (ip, entry['linenumber'], entry['chain'], entry['source'])) |
| if unban_line(entry['linenumber']): |
| print("Refreshing ban list...") |
| l = getbans() |
| else: |
| print("%s wasn't found in iptables, nothing to do" % ip) |
| return |
| |
| # CLI ban? |
| if args.ban: |
| ip = args.ban |
| found = inlist(l, ip) |
| if found: |
| print("%s is already banned here as %s, nothing to do" % (ip, found[0]['source'])) |
| else: |
| if ban(ip): |
| print("IP %s successfully banned using generic ruleset" % ip) |
| else: |
| print("Could not ban %s, bummer" % ip) |
| return |
| |
| # Daemon stuff? |
| d = asfpy.daemon(run_daemon) |
| |
| # Start daemon? |
| if args.daemonize: |
| d.start() |
| # stop daemon? |
| elif args.stop: |
| d.stop() |
| elif args.foreground: |
| run_daemon(True) |
| |
| if __name__ == '__main__': |
| start_client() |
| |