From b7636d170b7b9047ab3c2495887428e5b34149c7 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 8 Aug 2014 09:19:40 -0700 Subject: [PATCH] Implement a Linux firewall with iptables and ip6tables. --- akanda/router/api/v1/firewall.py | 82 +-------- akanda/router/defaults.py | 7 +- akanda/router/drivers/iptables.py | 259 +++++++++++++++++++++++++++++ akanda/router/manager.py | 13 +- test/unit/api/v1/test_firewall.py | 44 +---- test/unit/drivers/test_iptables.py | 153 +++++++++++++++++ 6 files changed, 434 insertions(+), 124 deletions(-) create mode 100755 akanda/router/drivers/iptables.py create mode 100644 test/unit/drivers/test_iptables.py diff --git a/akanda/router/api/v1/firewall.py b/akanda/router/api/v1/firewall.py index 0e43230..03c0b03 100644 --- a/akanda/router/api/v1/firewall.py +++ b/akanda/router/api/v1/firewall.py @@ -22,7 +22,7 @@ Blueprint for version 1 of the firewall API. from flask import request from akanda.router import utils -from akanda.router.drivers import pf +from akanda.router.drivers import iptables blueprint = utils.blueprint_factory(__name__) @@ -30,86 +30,12 @@ blueprint = utils.blueprint_factory(__name__) @blueprint.before_request def get_manager(): - request.pf_mgr = pf.PFManager() + request.iptables_mgr = iptables.IPTablesManager() @blueprint.route('/rules') def get_rules(): ''' - Show loaded firewall rules by pfctl + Show loaded firewall rules by iptables ''' - return request.pf_mgr.get_rules() - - -@blueprint.route('/states') -def get_states(): - ''' - Show firewall state table - ''' - return request.pf_mgr.get_states() - - -@blueprint.route('/anchors') -def get_anchors(): - ''' - Show loaded firewall anchors by pfctl - ''' - return request.pf_mgr.get_anchors() - - -@blueprint.route('/sources') -def get_sources(): - ''' - Show loaded firewall sources by pfctl - ''' - return request.pf_mgr.get_sources() - - -@blueprint.route('/info') -def get_info(): - ''' - Show verbose running firewall information - ''' - return request.pf_mgr.get_info() - - -@blueprint.route('/tables') -def get_tables(): - ''' - Show loaded firewall tables by pfctl - ''' - return request.pf_mgr.get_tables() - - -@blueprint.route('/labels') -@utils.json_response -def get_labels(): - ''' - Show loaded firewall labels by pfctl - ''' - return dict(labels=request.pf_mgr.get_labels()) - - -@blueprint.route('/labels', methods=['POST']) -@utils.json_response -def reset_labels(): - ''' - Show loaded firewall labels by pfctl and reset the counters - ''' - return dict(labels=request.pf_mgr.get_labels(True)) - - -@blueprint.route('/timeouts') -def get_timeouts(): - ''' - Show firewall connection timeouts - ''' - return request.pf_mgr.get_timeouts() - - -@blueprint.route('/memory') -def get_memory(): - ''' - Show firewall memory - ''' - return request.pf_mgr.get_memory() + return request.iptables_mgr.get_rules() diff --git a/akanda/router/defaults.py b/akanda/router/defaults.py index 37f1084..77a8e59 100644 --- a/akanda/router/defaults.py +++ b/akanda/router/defaults.py @@ -19,10 +19,14 @@ SSH = 22 SMTP = 25 DNS = 53 HTTP = 80 +BGP = 179 HTTPS = 443 HTTP_ALT = 8080 API_SERVICE = 5000 +DHCP = 67 +DHCPV6 = 546 + NFS_DEVELOPMENT = [111, 1110, 2049, 4045] MANAGEMENT_PORTS = [SSH, API_SERVICE] # + NFS_DEVELOPMENT @@ -46,4 +50,5 @@ RUG_META_PORT = 9697 def internal_metadata_port(ifname): - return BASE_METADATA_PORT + int(ifname[2:]) + import re + return BASE_METADATA_PORT + int(re.sub('[a-zA-Z]', '', ifname)) diff --git a/akanda/router/drivers/iptables.py b/akanda/router/drivers/iptables.py new file mode 100755 index 0000000..7b06cad --- /dev/null +++ b/akanda/router/drivers/iptables.py @@ -0,0 +1,259 @@ +# Copyright 2014 DreamHost, LLC +# +# Author: DreamHost, LLC +# +# Licensed 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 re + + +from akanda.router.drivers import base +from akanda.router.models import Network +from akanda.router import defaults, utils + + +class Rule(object): + + def __init__(self, rule, ip_version=None): + self.rule = rule + self.ip_version = ip_version + + def __str__(self): + return self.rule + + @property + def for_v4(self): + return self.ip_version in (None, 4) + + @property + def for_v6(self): + return self.ip_version in (None, 6) + + +class IPTablesManager(base.Manager): + """ + """ + + def save_config(self, config, interface_map): + rules = [] + + self._build_filter_table(config, interface_map, rules) + self._build_nat_table(config, interface_map, rules) + + v4_data = '\n'.join(map(str, filter(lambda x: x.for_v4, rules))) + v6_data = '\n'.join(map(str, filter(lambda x: x.for_v6, rules))) + + real_name = interface_map.get('ge0')[:-1] + + # Map virtual interface names + ifname_re = '\-(?Pi|o)(?P[\s!])(?P!?)(?Pge)(?P\d+)' # noqa + ifname_sub = r'-\g\g\g%s\g' % real_name + v4_data = re.sub(ifname_re, ifname_sub, v4_data) + '\n' + v6_data = re.sub(ifname_re, ifname_sub, v6_data) + '\n' + + utils.replace_file('/tmp/iptables.rules', v4_data) + utils.replace_file('/tmp/ip6tables.rules', v6_data) + + utils.execute( + ['mv', '/tmp/iptables.rules', '/etc/iptables/rules.v4'], + self.root_helper + ) + utils.execute( + ['mv', '/tmp/ip6tables.rules', '/etc/iptables/rules.v6'], + self.root_helper + ) + + def restart(self): + utils.execute( + ['/etc/init.d/iptables-persistent', 'restart'], + self.root_helper + ) + + def get_rules(self): + v4 = utils.execute(['iptables', '-L', '-n']) + v6 = utils.execute(['ip6tables', '-L', '-n']) + return v4 + v6 + + def get_external_network(self, config): + for n in config.networks: + if n.network_type == Network.TYPE_EXTERNAL: + return n + + def _build_filter_table(self, config, interface_map, rules): + ext_if = self.get_external_network(config).interface + + # Drop INPUT/OUTPUT by default + rules.extend([ + Rule('*filter'), + Rule(':INPUT DROP [0:0]'), + Rule(':FORWARD ACCEPT [0:0]'), + Rule(':OUTPUT ACCEPT [0:0]') + ]) + + # Allow ICMP and ICMP6 + rules.append(Rule( + '-A INPUT -p icmp --icmp-type echo-request -j ACCEPT', + ip_version=4 + )) + rules.append(Rule( + '-A INPUT -p icmpv6 --icmpv6-type echo-request -j ACCEPT', + ip_version=6 + )) + + for network in [ + n for n in config.networks + if n.network_type == Network.TYPE_MANAGEMENT + ]: + + # Open SSH, the HTTP API (5000) and the Nova metadata proxy (9697) + for port in ( + defaults.SSH, defaults.API_SERVICE, defaults.RUG_META_PORT + ): + rules.append(Rule( + '-A INPUT -i %s -p tcp -m tcp --dport %s -j ACCEPT' % ( + network.interface.ifname, + port + ), ip_version=6 + )) + + # Disallow any other management network traffic + rules.append(Rule('-A INPUT -i !%s -d %s -j DROP' % ( + network.interface.ifname, + network.interface.first_v6 + ), ip_version=6)) + + for network in [ + n for n in config.networks + if n.network_type == Network.TYPE_INTERNAL + ]: + + if network.interface.first_v4: + + # Basic v4 state-matching rules. Allows packets related to a + # pre-established session to pass. + rules.append(Rule( + '-A FORWARD -d %s -o %s -m state ' + '--state RELATED,ESTABLISHED -j ACCEPT' % ( + network.interface.first_v4, + network.interface.ifname + ), ip_version=4 + )) + + # Allow v4 DHCP + rules.append(Rule( + '-A INPUT -i %s -p udp -m udp --dport %s -j ACCEPT' % ( + network.interface.ifname, + defaults.DHCP + ), ip_version=4 + )) + rules.append(Rule( + '-A INPUT -i %s -p tcp -m tcp --dport %s -j ACCEPT' % ( + network.interface.ifname, + defaults.DHCP + ), ip_version=4 + )) + + if network.interface.first_v6: + + # Basic v6 state-matching rules. Allows packets related to a + # pre-established session to pass. + rules.append(Rule( + '-A FORWARD -d %s -o %s -m state ' + '--state RELATED,ESTABLISHED -j ACCEPT' % ( + network.interface.first_v6, + network.interface.ifname + ), ip_version=6 + )) + + # Allow v6 DHCP + rules.append(Rule( + '-A INPUT -i %s -p udp -m udp --dport %s -j ACCEPT' % ( + network.interface.ifname, + defaults.DHCPV6, + ), ip_version=6 + )) + rules.append(Rule( + '-A INPUT -i %s -p tcp -m tcp --dport %s -j ACCEPT' % ( + network.interface.ifname, + defaults.DHCPV6, + ), ip_version=6 + )) + + # Allow pre-established metadata sessions to pass + rules.append(Rule( + '-A FORWARD -s %s -o %s -m state ' + '--state RELATED,ESTABLISHED -j ACCEPT' % ( + defaults.METADATA_DEST_ADDRESS, + network.interface.ifname + ), ip_version=4 + )) + + rules.append(Rule( + '-A INPUT -i %s -j ACCEPT' % network.interface.ifname + )) + rules.append(Rule( + '-A INPUT -i %s -m state ' + '--state RELATED,ESTABLISHED -j ACCEPT' % ext_if.ifname + )) + + rules.append(Rule('COMMIT')) + + def _build_nat_table(self, config, interface_map, rules): + ext_if = self.get_external_network(config).interface + + rules.extend([ + Rule('*nat', ip_version=4), + Rule(':PREROUTING ACCEPT [0:0]', ip_version=4), + Rule(':INPUT ACCEPT [0:0]', ip_version=4), + Rule(':OUTPUT ACCEPT [0:0]', ip_version=4), + Rule(':POSTROUTING ACCEPT [0:0]', ip_version=4), + ]) + + for network in config.networks: + + if network.network_type == Network.TYPE_INTERNAL: + + # Forward metadata requests on the management interface + rules.append(Rule( + '-A PREROUTING -s %s -d %s -p tcp -m tcp ' + '--dport %s -j DNAT --to-destination 127.0.0.1:%s' % ( + network.interface.first_v4, + defaults.METADATA_DEST_ADDRESS, + defaults.HTTP, + defaults.internal_metadata_port( + network.interface.ifname + ) + ), ip_version=4 + )) + + # NAT for IPv4 + ext_v4 = sorted( + a.ip for a in ext_if._addresses if a.version == 4 + )[0] + rules.append(Rule( + '-A POSTROUTING -s %s -o %s -j SNAT --to %s' % ( + network.interface.first_v4, + network.interface.ifname, + str(ext_v4) + ), ip_version=4 + )) + + # Route floating IP addresses + for fip in self.get_external_network(config).floating_ips: + rules.append(Rule('-A POSTROUTING -o %s -s %s -j SNAT --to %s' % ( + ext_if.ifname, + fip.fixed_ip, + fip.floating_ip + ), ip_version=4)) + + rules.append(Rule('COMMIT', ip_version=4)) diff --git a/akanda/router/manager.py b/akanda/router/manager.py index f007cfd..6aecc8b 100644 --- a/akanda/router/manager.py +++ b/akanda/router/manager.py @@ -19,7 +19,7 @@ import os import re from akanda.router import models -from akanda.router.drivers import (bird, dnsmasq, ip, metadata, pf, arp) +from akanda.router.drivers import (bird, dnsmasq, ip, metadata, iptables, arp) class Manager(object): @@ -49,7 +49,7 @@ class Manager(object): self.update_dhcp() self.update_metadata() self.update_bgp_and_radv() - self.update_pf() + self.update_firewall() self.update_routes(cache) self.update_arp() @@ -80,11 +80,10 @@ class Manager(object): mgr.save_config(self.config, self.ip_mgr.generic_mapping) mgr.restart() - def update_pf(self): - rule_data = self.config.pf_config - rule_data = self._map_virtual_to_real_interfaces(rule_data) - mgr = pf.PFManager() - mgr.update_conf(rule_data) + def update_firewall(self): + mgr = iptables.IPTablesManager() + mgr.save_config(self.config, self.ip_mgr.generic_mapping) + mgr.restart() def update_routes(self, cache): mgr = ip.IPManager() diff --git a/test/unit/api/v1/test_firewall.py b/test/unit/api/v1/test_firewall.py index 9366d98..7142c4b 100644 --- a/test/unit/api/v1/test_firewall.py +++ b/test/unit/api/v1/test_firewall.py @@ -24,15 +24,17 @@ import mock from unittest2 import TestCase from akanda.router.api import v1 -from akanda.router.drivers.pf import PFManager class FirewallAPITestCase(TestCase): """ """ def setUp(self): - pf_mgr_patch = mock.patch.object(v1.firewall.pf, 'PFManager') - self.pf_mgr = pf_mgr_patch.start().return_value + ip_mgr_patch = mock.patch.object( + v1.firewall.iptables, + 'IPTablesManager' + ) + self.iptables_mgr = ip_mgr_patch.start().return_value self.addCleanup(mock.patch.stopall) self.app = flask.Flask('firewall_test') self.app.register_blueprint(v1.firewall.blueprint) @@ -40,7 +42,7 @@ class FirewallAPITestCase(TestCase): def _test_passthrough_helper(self, resource_name, method_name, response_code=200): - mock_method = getattr(self.pf_mgr, method_name) + mock_method = getattr(self.iptables_mgr, method_name) mock_method.return_value = 'the_value' result = self.test_app.get('/v1/firewall/%s' % resource_name) self.assertEqual(response_code, result.status_code) @@ -49,37 +51,3 @@ class FirewallAPITestCase(TestCase): def test_get_rules(self): self._test_passthrough_helper('rules', 'get_rules') - - def test_get_states(self): - self._test_passthrough_helper('states', 'get_states') - - def test_get_anchors(self): - self._test_passthrough_helper('anchors', 'get_anchors') - - def test_get_sources(self): - self._test_passthrough_helper('sources', 'get_sources') - - def test_get_info(self): - self._test_passthrough_helper('info', 'get_info') - - def test_get_timeouts(self): - self._test_passthrough_helper('timeouts', 'get_timeouts') - - def test_get_tables(self): - self._test_passthrough_helper('tables', 'get_tables') - - def test_get_memory(self): - self._test_passthrough_helper('memory', 'get_memory') - - def test_get_labels(self, reset_flag=False): - expected = {'labels': 'thelabels'} - self.pf_mgr.get_labels.return_value = 'thelabels' - method = 'post' if reset_flag else 'get' - args = (True, ) if reset_flag else () - result = getattr(self.test_app, method)('/v1/firewall/labels') - self.assertEqual(result.status_code, 200) - self.pf_mgr.get_labels.assert_called_once_with(*args) - self.assertEqual(json.loads(result.data), expected) - - def test_get_labels_reset(self): - self.test_get_labels(True) diff --git a/test/unit/drivers/test_iptables.py b/test/unit/drivers/test_iptables.py new file mode 100644 index 0000000..ed68708 --- /dev/null +++ b/test/unit/drivers/test_iptables.py @@ -0,0 +1,153 @@ +from unittest import TestCase + +import mock + +from akanda.router import models +from akanda.router.drivers import iptables + +CONFIG = models.Configuration({ + 'networks': [{ + 'network_id': 'ABC123', + 'interface': { + 'ifname': 'eth0', + 'addresses': [ + 'fdca:3ba5:a17a:acda:f816:3eff:fe66:33b6/64', + 'fe80::f816:3eff:fe66:33b6/64' + ] + }, + 'name': 'mgt', + 'network_type': models.Network.TYPE_MANAGEMENT, + }, { + 'network_id': 'ABC456', + 'interface': { + 'ifname': 'eth1', + 'addresses': [ + '172.16.77.2/24', + 'fdee:9f85:83be:0:f816:3eff:fe42:a9f/48' + ] + }, + 'name': 'ext', + 'network_type': models.Network.TYPE_EXTERNAL, + 'subnets': [{ + 'cidr': '172.16.77.0/24', + 'gateway_ip': '172.16.77.1', + 'dhcp_enabled': True, + 'dns_nameservers': [] + }] + }, { + 'network_id': 'ABC789', + 'interface': { + 'ifname': 'eth2', + 'addresses': [ + '192.168.0.1/24', + 'fdd6:a1fa:cfa8:9df::1/64' + ] + }, + 'name': 'internal', + 'network_type': models.Network.TYPE_INTERNAL, + 'subnets': [{ + 'cidr': '192.168.0.0/24', + 'gateway_ip': '192.168.0.1', + 'dhcp_enabled': True, + 'dns_nameservers': [] + }] + }], + 'floating_ips': [{ + 'fixed_ip': '192.168.0.2', + 'floating_ip': '172.16.77.50' + }] +}) + +V4_OUTPUT = [ + '*filter', + ':INPUT DROP [0:0]', + ':FORWARD ACCEPT [0:0]', + ':OUTPUT ACCEPT [0:0]', + '-A INPUT -p icmp --icmp-type echo-request -j ACCEPT', + '-A FORWARD -d 192.168.0.1 -o eth2 -m state --state RELATED,ESTABLISHED -j ACCEPT', # noqa + '-A INPUT -i eth2 -p udp -m udp --dport 67 -j ACCEPT', + '-A INPUT -i eth2 -p tcp -m tcp --dport 67 -j ACCEPT', + '-A FORWARD -s 169.254.169.254 -o eth2 -m state --state RELATED,ESTABLISHED -j ACCEPT', # noqa + '-A INPUT -i eth2 -j ACCEPT', + '-A INPUT -i eth1 -m state --state RELATED,ESTABLISHED -j ACCEPT', + 'COMMIT', + '*nat', + ':PREROUTING ACCEPT [0:0]', + ':INPUT ACCEPT [0:0]', + ':OUTPUT ACCEPT [0:0]', + ':POSTROUTING ACCEPT [0:0]', + '-A PREROUTING -s 192.168.0.1 -d 169.254.169.254 -p tcp -m tcp --dport 80 -j DNAT --to-destination 127.0.0.1:9602', # noqa + '-A POSTROUTING -s 192.168.0.1 -o eth2 -j SNAT --to 172.16.77.2', + '-A POSTROUTING -o eth1 -s 192.168.0.2 -j SNAT --to 172.16.77.50', + 'COMMIT' +] + +V6_OUTPUT = [ + '*filter', + ':INPUT DROP [0:0]', + ':FORWARD ACCEPT [0:0]', + ':OUTPUT ACCEPT [0:0]', + '-A INPUT -p icmpv6 --icmpv6-type echo-request -j ACCEPT', + '-A INPUT -i eth0 -p tcp -m tcp --dport 22 -j ACCEPT', + '-A INPUT -i eth0 -p tcp -m tcp --dport 5000 -j ACCEPT', + '-A INPUT -i eth0 -p tcp -m tcp --dport 9697 -j ACCEPT', + '-A INPUT -i !eth0 -d fdca:3ba5:a17a:acda:f816:3eff:fe66:33b6 -j DROP', + '-A FORWARD -d fdd6:a1fa:cfa8:9df::1 -o eth2 -m state --state RELATED,ESTABLISHED -j ACCEPT', # noqa + '-A INPUT -i eth2 -p udp -m udp --dport 546 -j ACCEPT', + '-A INPUT -i eth2 -p tcp -m tcp --dport 546 -j ACCEPT', + '-A INPUT -i eth2 -j ACCEPT', + '-A INPUT -i eth1 -m state --state RELATED,ESTABLISHED -j ACCEPT', + 'COMMIT' +] + + +class TestIPTablesConfiguration(TestCase): + + def setUp(self): + super(TestIPTablesConfiguration, self).setUp() + self.execute = mock.patch('akanda.router.utils.execute').start() + self.replace = mock.patch('akanda.router.utils.replace_file').start() + self.patches = [self.execute, self.replace] + + def tearDown(self): + super(TestIPTablesConfiguration, self).tearDown() + for p in self.patches: + p.stop() + + def test_complete(self): + mgr = iptables.IPTablesManager() + mgr.save_config(CONFIG, { + 'ge0': 'eth0', + 'ge1': 'eth1', + 'ge2': 'eth2' + }) + + assert self.replace.call_count == 2 + + assert mock.call( + '/tmp/iptables.rules', + '\n'.join(V4_OUTPUT) + '\n' + ) in self.replace.call_args_list + + assert mock.call( + '/tmp/ip6tables.rules', + '\n'.join(V6_OUTPUT) + '\n' + ) in self.replace.call_args_list + + assert self.execute.call_args_list == [ + mock.call( + ['mv', '/tmp/iptables.rules', '/etc/iptables/rules.v4'], + 'sudo' + ), + mock.call( + ['mv', '/tmp/ip6tables.rules', '/etc/iptables/rules.v6'], + 'sudo' + ) + ] + + def test_restart(self): + mgr = iptables.IPTablesManager() + mgr.restart() + assert self.execute.call_args_list == [ + mock.call(['/etc/init.d/iptables-persistent', 'restart'], 'sudo') + ]