components/openstack/neutron/files/agent/solaris/pf_firewall.py
author Girish Moodalbail <Girish.Moodalbail@oracle.COM>
Tue, 15 Nov 2016 18:00:12 -0800
changeset 7315 5cc40226273b
permissions -rw-r--r--
PSARC/2016/531 OpenStack Neutron Security Groups 24691270 add support for OpenStack Neutron Security Groups

# Copyright 2012, Nachi Ueno, NTT MCL, Inc.
# All Rights Reserved.
#
# Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved.
#
#    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 collections

import netaddr
from oslo_config import cfg
from oslo_log import log as logging
import six

from neutron._i18n import _LI
from neutron.agent.common import ovs_lib
from neutron.agent import firewall
from neutron.agent.solaris import packetfilter
from neutron.common import constants
from neutron.common import utils as c_utils


LOG = logging.getLogger(__name__)
ICMPV6_ALLOWED_UNSPEC_ADDR_TYPES = [131, 135, 143]

DIRECTION_PF_PARAM = {firewall.INGRESS_DIRECTION: 'in',
                      firewall.EGRESS_DIRECTION: 'out'}


class PFBaseOVS(ovs_lib.BaseOVS):
    def get_port_by_id(self, port_id):
        ports = self.ovsdb.db_find(
            'Interface', ('external_ids', '=', {'iface-id': port_id}),
            columns=['name']).execute()
        if ports:
            return ports[0]['name']
        return None


class PFFirewallDriver(firewall.FirewallDriver):
    """Driver which enforces security groups through PF rules.

    Please look at neutron.agent.firewall.FirewallDriver for more information
    on how the methods below are called from the Neutron Open vSwitch agent. It
    all starts at prepare_port_filter() and then _setup_pf_rules() has all the
    PF based logic to add correct rules on guest instance's port.
    """

    def __init__(self):
        self.pf = packetfilter.PacketFilter("_auto/neutron:ovs:agent",
                                            layer2=True)
        # List of port which has security group
        self.filtered_ports = {}
        self.unfiltered_ports = {}
        # List of security group rules for ports residing on this host
        self.sg_rules = {}
        # List of security group member ips for ports residing on this host
        self.sg_members = collections.defaultdict(
            lambda: collections.defaultdict(list))
        # Every PF rule needs to be labeled so that we can later kill the state
        # associated with that rule (using pfctl -k label -k 110). It is hard
        # to come up with a meaningfully named label for each PF rule, so we
        # are resorting to numbers here.
        self.label_num = 100
        self.portid_to_devname = {}

    def prepare_port_filter(self, port):
        LOG.debug("Preparing device (%s) filter", port['device'])
        self._setup_pf_rules(port)

    def apply_port_filter(self, port):
        """We never call this method

        It exists here to override abstract method of parent abstract class.
        """
        pass

    def update_port_filter(self, port):
        LOG.debug("Updating device (%s) filter", port['device'])
        self._setup_pf_rules(port, update=True)

    def remove_port_filter(self, port):
        LOG.debug("Removing device (%s) filter", port['device'])
        self.unfiltered_ports.pop(port['device'], None)
        self.filtered_ports.pop(port['device'], None)
        self._remove_rule_port_sec(port)

    @property
    def ports(self):
        return dict(self.filtered_ports, **self.unfiltered_ports)

    def update_security_group_rules(self, sg_id, sg_rules):
        LOG.debug("Update rules of security group %s(%s)" % (sg_id, sg_rules))
        self.sg_rules[sg_id] = sg_rules

    def update_security_group_members(self, sg_id, sg_members):
        LOG.debug("Update members of security group %s(%s)" %
                  (sg_id, sg_members))
        self.sg_members[sg_id] = collections.defaultdict(list, sg_members)

    def security_group_updated(self, action_type, sec_group_ids,
                               device_ids=None):
        # TODO(gmoodalb): Extend this later to optimize handling of security
        # groups update
        pass

    def _get_label_number(self):
        self.label_num += 1
        return self.label_num

    def _remove_rule_port_sec(self, port):
        device_name = self.portid_to_devname.pop(port['id'], None)
        if not device_name:
            LOG.info(_LI("Could not find port: %s. Failed to remove PF rules "
                         "for that port"), port['id'])
            return
        LOG.debug("Removing PF rules for device_name(%s)" % device_name)
        # we need to remove both ingress and egress
        if '/' in device_name:
            instance_name, datalink = device_name.split('/')
            instance_name = instance_name.split(':')[1]
            ingress = '%s_in' % datalink
            egress = '%s_out' % datalink
        else:
            instance_name = device_name
            ingress = 'in'
            egress = 'out'
        existing_anchor_rules = set(self.pf.list_anchor_rules([instance_name]))
        existing_anchor_rules.discard('anchor "%s" in on %s all' %
                                      (ingress, device_name))
        existing_anchor_rules.discard('anchor "%s" out on %s all' %
                                      (egress, device_name))
        self.pf.add_rules(list(existing_anchor_rules), [instance_name])
        if existing_anchor_rules:
            self.pf.remove_anchor_recursively([instance_name, ingress])
            self.pf.remove_anchor_recursively([instance_name, egress])
        else:
            self.pf.remove_anchor_recursively([instance_name])

    def _setup_pf_rules(self, port, update=False):
        if not firewall.port_sec_enabled(port):
            self.unfiltered_ports[port['device']] = port
            self.filtered_ports.pop(port['device'], None)
            self._remove_rule_port_sec(port)
        else:
            self.filtered_ports[port['device']] = port
            self.unfiltered_ports.pop(port['device'], None)
            if update:
                self._remove_rule_port_sec(port)
            self._add_rules_by_security_group(port, firewall.INGRESS_DIRECTION)
            self._add_rules_by_security_group(port, firewall.EGRESS_DIRECTION)

    def _get_device_name(self, port):
        bridge = PFBaseOVS()
        device_name = bridge.get_port_by_id(port['id'])
        if '/' in device_name:
            device_name = 'dl:' + device_name
        return device_name

    def _split_sgr_by_ethertype(self, security_group_rules):
        ipv4_sg_rules = []
        ipv6_sg_rules = []
        for rule in security_group_rules:
            if rule.get('ethertype') == constants.IPv4:
                ipv4_sg_rules.append(rule)
            elif rule.get('ethertype') == constants.IPv6:
                if rule.get('protocol') in ['icmp', 'icmp6']:
                    rule['protocol'] = 'ipv6-icmp'
                ipv6_sg_rules.append(rule)
        return ipv4_sg_rules, ipv6_sg_rules

    def _select_sgr_by_direction(self, port, direction):
        return [rule
                for rule in port.get('security_group_rules', [])
                if rule['direction'] == direction]

    def _spoofing_rule(self, port, device_name, ipv4_rules, ipv6_rules):
        # Fixed rules for traffic sourced from unspecified addresses: 0.0.0.0
        # and ::
        # Allow dhcp client discovery and request
        ipv4_rules.append('pass out on %s proto udp from 0.0.0.0/32 port 68 '
                          'to 255.255.255.255/32 port 67 label "%s"' %
                          (device_name, self._get_label_number()))

        # Allow neighbor solicitation and multicast listener discovery
        # from the unspecified address for duplicate address detection
        for icmp6_type in ICMPV6_ALLOWED_UNSPEC_ADDR_TYPES:
            ipv6_rules.append('pass out on %s inet6 proto ipv6-icmp '
                              'from ::/128 to ff02::/16 icmp6-type %s '
                              'label "%s"' % (device_name, icmp6_type,
                                              self._get_label_number()))

        # Fixed rules for traffic after source address is verified
        # Allow dhcp client renewal and rebinding
        ipv4_rules.append('pass out on %s proto udp from port 68 to port 67 '
                          'label "%s"' % (device_name,
                                          self._get_label_number()))

        # Drop Router Advts from the port.
        ipv6_rules.append('block out quick on %s inet6 proto ipv6-icmp '
                          'icmp6-type %s label "%s"' %
                          (device_name, constants.ICMPV6_TYPE_RA,
                           self._get_label_number()))
        # Allow IPv6 ICMP traffic
        ipv6_rules.append('pass out on %s inet6 proto ipv6-icmp label "%s"' %
                          (device_name, self._get_label_number()))
        # Allow IPv6 DHCP Client traffic
        ipv6_rules.append('pass out on %s inet6 proto udp from port 546 '
                          'to port 547 label "%s"' %
                          (device_name, self._get_label_number()))

    def _drop_dhcp_rule(self, device_name, ipv4_rules, ipv6_rules):
        # Drop dhcp packet from VM
        ipv4_rules.append('block out quick on %s proto udp from port 67 '
                          'to port 68 label "%s"' %
                          (device_name, self._get_label_number()))
        ipv6_rules.append('block out quick on %s inet6 proto udp '
                          'from port 547 to port 546 label "%s"' %
                          (device_name, self._get_label_number()))

    def _accept_inbound_icmpv6(self, device_name, ipv6_pf_rules):
        # Allow multicast listener, neighbor solicitation and
        # neighbor advertisement into the instance
        for icmp6_type in constants.ICMPV6_ALLOWED_TYPES:
            ipv6_pf_rules.append('pass in on %s inet6 proto ipv6-icmp '
                                 'icmp6-type %s label "%s"' %
                                 (device_name, icmp6_type,
                                  self._get_label_number()))

    def _select_sg_rules_for_port(self, port, direction):
        """Select rules from the security groups the port is member of."""
        port_sg_ids = port.get('security_groups', [])
        port_rules = []

        for sg_id in port_sg_ids:
            for rule in self.sg_rules.get(sg_id, []):
                if rule['direction'] == direction:
                    port_rules.extend(
                        self._expand_sg_rule_with_remote_ips(
                            rule, port, direction))
        return port_rules

    def _expand_sg_rule_with_remote_ips(self, rule, port, direction):
        """Expand a remote group rule to rule per remote group IP."""
        remote_group_id = rule.get('remote_group_id')
        if remote_group_id:
            ethertype = rule['ethertype']
            port_ips = port.get('fixed_ips', [])
            LOG.debug("Expanding rule: %s with remote IPs: %s" %
                      (rule, self.sg_members[remote_group_id][ethertype]))
            for ip in self.sg_members[remote_group_id][ethertype]:
                if ip not in port_ips:
                    ip_rule = rule.copy()
                    direction_ip_prefix = firewall.DIRECTION_IP_PREFIX[
                        direction]
                    ip_prefix = str(netaddr.IPNetwork(ip).cidr)
                    ip_rule[direction_ip_prefix] = ip_prefix
                    yield ip_rule
        else:
            yield rule

    def _get_remote_sg_ids(self, port, direction=None):
        sg_ids = port.get('security_groups', [])
        remote_sg_ids = {constants.IPv4: set(), constants.IPv6: set()}
        for sg_id in sg_ids:
            for rule in self.sg_rules.get(sg_id, []):
                if not direction or rule['direction'] == direction:
                    remote_sg_id = rule.get('remote_group_id')
                    ether_type = rule.get('ethertype')
                    if remote_sg_id and ether_type:
                        remote_sg_ids[ether_type].add(remote_sg_id)
        return remote_sg_ids

    def _add_pf_rules(self, port, device_name, direction, ipv4_pf_rules,
                      ipv6_pf_rules):
        if '/' in device_name:
            instance_name, datalink = device_name.split('/')
            instance_name = instance_name.split(':')[1]
        else:
            instance_name, datalink = (device_name, "")
        self.pf.add_nested_anchor_rule(None, instance_name, None)
        if direction == firewall.INGRESS_DIRECTION:
            subanchor = '%s%s' % (datalink, '_in' if datalink else 'in')
            new_anchor_rule = ['anchor "%s" in on %s all' % (subanchor,
                                                             device_name)]
        else:
            subanchor = '%s%s' % (datalink, '_out' if datalink else 'out')
            new_anchor_rule = ['anchor "%s" out on %s all' % (subanchor,
                                                              device_name)]
        existing_anchor_rules = self.pf.list_anchor_rules([instance_name])
        final_anchor_rules = set(existing_anchor_rules) | set(new_anchor_rule)
        self.pf.add_rules(list(final_anchor_rules), [instance_name])

        # self.pf.add_nested_anchor_rule(None, anchor_name, anchor_option)
        self.pf.add_rules(ipv4_pf_rules + ipv6_pf_rules,
                          [instance_name, subanchor])
        self.portid_to_devname[port['id']] = device_name

    def _add_block_everything(self, device_name, direction, ipv4_pf_rules,
                              ipv6_pf_rules):
        ''' Add a generic block everything rule. The default security group
        in OpenStack adds 'pass all egress traffic' and prevents all the
        incoming traffic'''
        ipv4_pf_rules.append('block %s on %s label "%s"' %
                             (DIRECTION_PF_PARAM[direction], device_name,
                              self._get_label_number()))
        ipv6_pf_rules.append('block %s on %s inet6 label "%s"' %
                             (DIRECTION_PF_PARAM[direction], device_name,
                              self._get_label_number()))

    def _add_rules_by_security_group(self, port, direction):
        LOG.debug("Adding rules for Port: %s", port)

        device_name = self._get_device_name(port)
        if not device_name:
            LOG.info(_LI("Could not find port: %s on the OVS bridge. Failed "
                         "to add PF rules for that port"), port['id'])
            return
        # select rules for current port and direction
        security_group_rules = self._select_sgr_by_direction(port, direction)
        security_group_rules += self._select_sg_rules_for_port(port, direction)
        # split groups by ip version
        # for ipv4, 'pass' will be used
        # for ipv6, 'pass inet6' will be used
        ipv4_sg_rules, ipv6_sg_rules = self._split_sgr_by_ethertype(
            security_group_rules)
        ipv4_pf_rules = []
        ipv6_pf_rules = []
        self._add_block_everything(device_name, direction, ipv4_pf_rules,
                                   ipv6_pf_rules)
        # include fixed egress/ingress rules
        if direction == firewall.EGRESS_DIRECTION:
            self._add_fixed_egress_rules(port, device_name, ipv4_pf_rules,
                                         ipv6_pf_rules)
        elif direction == firewall.INGRESS_DIRECTION:
            self._accept_inbound_icmpv6(device_name, ipv6_pf_rules)
        # include IPv4 and IPv6 iptable rules from security group
        LOG.debug("Converting %s IPv4 SG rules: %s" %
                  (direction, ipv4_sg_rules))
        ipv4_pf_rules += self._convert_sgr_to_pfr(device_name, direction,
                                                  ipv4_sg_rules)
        LOG.debug("... to %s IPv4 PF rules: %s" % (direction, ipv4_pf_rules))
        LOG.debug("Converting %s IPv6 SG rules: %s" %
                  (direction, ipv6_sg_rules))
        ipv6_pf_rules += self._convert_sgr_to_pfr(device_name, direction,
                                                  ipv6_sg_rules)
        LOG.debug("... to %s IPv6 PF rules: %s" % (direction, ipv6_pf_rules))

        self._add_pf_rules(port, device_name, direction, ipv4_pf_rules,
                           ipv6_pf_rules)

    def _add_fixed_egress_rules(self, port, device_name, ipv4_pf_rules,
                                ipv6_pf_rules):
        self._spoofing_rule(port, device_name, ipv4_pf_rules, ipv6_pf_rules)
        self._drop_dhcp_rule(device_name, ipv4_pf_rules, ipv6_pf_rules)

    def _protocol_param(self, protocol, pf_rule):
        if protocol:
            pf_rule.append('proto %s' % protocol)

    def _port_param(self, protocol, port_range_min, port_range_max, pf_rule):
        if port_range_min is None:
            return
        if protocol in ('tcp', 'udp'):
            if port_range_min == port_range_max:
                pf_rule.append('port %s' % port_range_min)
            else:
                pf_rule.append('port %s:%s' % (port_range_min,
                                               port_range_max))
        elif protocol in ('icmp', 'ipv6-icmp'):
            icmp_type = 'icmp-type' if protocol == 'icmp' else 'icmp6-type'
            pf_rule.append('%s %s' % (icmp_type, port_range_min))
            if port_range_max is not None:
                pf_rule.append('code %s' % port_range_max)

    def _ip_prefix_param(self, direction, ip_prefix, pf_rule):
        if ip_prefix != 'any':
            if '/' not in ip_prefix:
                # we need to convert it into a cidr
                ip_prefix = c_utils.ip_to_cidr(ip_prefix)
            elif ip_prefix.endswith('/0'):
                ip_prefix = 'any'
        direction = 'from' if direction == firewall.INGRESS_DIRECTION else 'to'
        pf_rule.append('%s %s' % (direction, ip_prefix))

    def _ip_prefix_port_param(self, direction, sg_rule, pf_rule):
        protocol = sg_rule.get('protocol')
        if direction == firewall.INGRESS_DIRECTION:
            ip_prefix = sg_rule.get('source_ip_prefix')
            ip_prefix = ip_prefix if ip_prefix else 'any'
            self._ip_prefix_param(direction, ip_prefix, pf_rule)
            self._port_param(protocol,
                             sg_rule.get('source_port_range_min'),
                             sg_rule.get('source_port_range_max'), pf_rule)
            self._ip_prefix_param(firewall.EGRESS_DIRECTION, 'any', pf_rule)
            self._port_param(protocol,
                             sg_rule.get('port_range_min'),
                             sg_rule.get('port_range_max'), pf_rule)
        else:
            self._ip_prefix_param(firewall.INGRESS_DIRECTION, 'any', pf_rule)
            self._port_param(protocol,
                             sg_rule.get('source_port_range_min'),
                             sg_rule.get('source_port_range_max'), pf_rule)

            ip_prefix = sg_rule.get('dest_ip_prefix')
            ip_prefix = ip_prefix if ip_prefix else 'any'
            self._ip_prefix_param(direction, ip_prefix, pf_rule)
            self._port_param(protocol,
                             sg_rule.get('port_range_min'),
                             sg_rule.get('port_range_max'), pf_rule)

    def _convert_sgr_to_pfr(self, device_name, direction,
                            security_group_rules):
        pf_rules = []
        for sg_rule in security_group_rules:
            pf_rule = ['pass']
            pf_rule.append("%s on %s" % (DIRECTION_PF_PARAM[direction],
                                         device_name))
            if sg_rule.get('ethertype') == constants.IPv6:
                pf_rule.append('inet6')
            else:
                pf_rule.append('inet')
            protocol = sg_rule.get('protocol')
            self._protocol_param(protocol, pf_rule)
            self._ip_prefix_port_param(direction, sg_rule, pf_rule)
            pf_rule.append('label "%s"' % self._get_label_number())
            pf_rules.append(' '.join(pf_rule))
        return pf_rules