Implement a Linux firewall with iptables and ip6tables.

This commit is contained in:
Ryan Petrello 2014-08-08 09:19:40 -07:00
parent 65c952f5d8
commit b7636d170b
6 changed files with 434 additions and 124 deletions

View File

@ -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()

View File

@ -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))

259
akanda/router/drivers/iptables.py Executable file
View File

@ -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 = '\-(?P<flag>i|o)(?P<ws>[\s!])(?P<not>!?)(?P<if>ge)(?P<no>\d+)' # noqa
ifname_sub = r'-\g<flag>\g<ws>\g<not>%s\g<no>' % 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))

View File

@ -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()

View File

@ -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)

View File

@ -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')
]