diff options
author | Johan Lundberg <lundberg@nordu.net> | 2015-04-02 10:43:33 +0200 |
---|---|---|
committer | Johan Lundberg <lundberg@nordu.net> | 2015-04-02 10:43:33 +0200 |
commit | bd611ac59f7c4db885a2f8631ef0bcdcd1901ca0 (patch) | |
tree | e60f5333a7699cd021b33c7f5292af55b774001b /lib/aclgenerator.py |
Diffstat (limited to 'lib/aclgenerator.py')
-rwxr-xr-x | lib/aclgenerator.py | 418 |
1 files changed, 418 insertions, 0 deletions
diff --git a/lib/aclgenerator.py b/lib/aclgenerator.py new file mode 100755 index 0000000..c5be343 --- /dev/null +++ b/lib/aclgenerator.py @@ -0,0 +1,418 @@ +#!/usr/bin/python2.4 +# +# Copyright 2011 Google Inc. 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. +# + +"""ACL Generator base class.""" + +import copy +import re +from string import Template + +import policy + + +# generic error class +class Error(Exception): + """Base error class.""" + pass + + +class NoPlatformPolicyError(Error): + """Raised when a policy is received that doesn't support this platform.""" + pass + + +class UnsupportedFilter(Error): + """Raised when we see an inappropriate filter.""" + pass + + +class UnknownIcmpTypeError(Error): + """Raised when we see an unknown icmp-type.""" + pass + + +class MismatchIcmpInetError(Error): + """Raised when mistmatch between icmp/icmpv6 and inet/inet6.""" + pass + + +class EstablishedError(Error): + """Raised when a term has established option with inappropriate protocol.""" + pass + + +class UnsupportedAF(Error): + """Raised when provided an unsupported address family.""" + pass + + +class DuplicateTermError(Error): + """Raised when duplication of term names are detected.""" + pass + + +class UnsupportedFilterError(Error): + """Raised when we see an inappropriate filter.""" + pass + + +class TermNameTooLongError(Error): + """Raised when term named can not be abbreviated.""" + pass + + +class Term(object): + """Generic framework for a generator Term.""" + ICMP_TYPE = policy.Term.ICMP_TYPE + PROTO_MAP = {'ip': 0, + 'icmp': 1, + 'igmp': 2, + 'ggp': 3, + 'ipencap': 4, + 'tcp': 6, + 'egp': 8, + 'igp': 9, + 'udp': 17, + 'rdp': 27, + 'ipv6': 41, + 'ipv6-route': 43, + 'ipv6-frag': 44, + 'rsvp': 46, + 'gre': 47, + 'esp': 50, + 'ah': 51, + 'icmpv6': 58, + 'ipv6-nonxt': 59, + 'ipv6-opts': 60, + 'ospf': 89, + 'ipip': 94, + 'pim': 103, + 'vrrp': 112, + 'l2tp': 115, + 'sctp': 132, + } + AF_MAP = {'inet': 4, + 'inet6': 6, + 'bridge': 4 # if this doesn't exist, output includes v4 & v6 + } + # provide flipped key/value dicts + PROTO_MAP_BY_NUMBER = dict([(v, k) for (k, v) in PROTO_MAP.iteritems()]) + AF_MAP_BY_NUMBER = dict([(v, k) for (k, v) in AF_MAP.iteritems()]) + + NO_AF_LOG_FORMAT = Template('Term $term will not be rendered, as it has' + ' $direction address match specified but no' + ' $direction addresses of $af address family' + ' are present.') + + def NormalizeAddressFamily(self, af): + """Convert (if necessary) address family name to numeric value. + + Args: + af: Address family, can be either numeric or string (e.g. 4 or 'inet') + + Returns: + af: Numeric address family value + + Raises: + UnsupportedAF: Address family not in keys or values of our AF_MAP. + """ + # ensure address family (af) is valid + if af in self.AF_MAP_BY_NUMBER: + return af + elif af in self.AF_MAP: + # convert AF name to number (e.g. 'inet' becomes 4, 'inet6' becomes 6) + af = self.AF_MAP[af] + else: + raise UnsupportedAF('Address family %s is not supported, term %s.' % ( + af, self.term.name)) + return af + + def NormalizeIcmpTypes(self, icmp_types, protocols, af): + """Return verified list of appropriate icmp-types. + + Args: + icmp_types: list of icmp_types + protocols: list of protocols + af: address family of this term, either numeric or text (see self.AF_MAP) + + Returns: + sorted list of numeric icmp-type codes. + + Raises: + UnsupportedFilterError: icmp-types specified with non-icmp protocol. + MismatchIcmpInetError: mismatch between icmp protocol and address family. + UnknownIcmpTypeError: unknown icmp-type specified + """ + if not icmp_types: + return [''] + # only protocols icmp or icmpv6 can be used with icmp-types + if protocols != ['icmp'] and protocols != ['icmpv6']: + raise UnsupportedFilterError('%s %s' % ( + 'icmp-types specified for non-icmp protocols in term: ', + self.term.name)) + # make sure we have a numeric address family (4 or 6) + af = self.NormalizeAddressFamily(af) + # check that addr family and protocl are appropriate + if ((af != 4 and protocols == ['icmp']) or + (af != 6 and protocols == ['icmpv6'])): + raise MismatchIcmpInetError('%s %s' % ( + 'ICMP/ICMPv6 mismatch with address family IPv4/IPv6 in term', + self.term.name)) + # ensure all icmp types are valid + for icmptype in icmp_types: + if icmptype not in self.ICMP_TYPE[af]: + raise UnknownIcmpTypeError('%s %s %s %s' % ( + '\nUnrecognized ICMP-type (', icmptype, + ') specified in term ', self.term.name)) + rval = [] + rval.extend([self.ICMP_TYPE[af][x] for x in icmp_types]) + rval.sort() + return rval + + +class ACLGenerator(object): + """Generates platform specific filters and terms from a policy object. + + This class takes a policy object and renders the output into a syntax which + is understood by a specific platform (eg. iptables, cisco, etc). + """ + + _PLATFORM = None + # Default protocol to apply when no protocol is specified. + _DEFAULT_PROTOCOL = 'ip' + # Unsupported protocols by address family. + _SUPPORTED_AF = set(('inet', 'inet6')) + # Commonly misspelled protocols that the generator should reject. + _FILTER_BLACKLIST = {} + + # Set of required keywords that every generator must support. + _REQUIRED_KEYWORDS = set(['action', + 'comment', + 'destination_address', + 'destination_address_exclude', + 'destination_port', + 'icmp_type', + 'name', # obj attribute, not keyword + 'option', + 'protocol', + 'platform', + 'platform_exclude', + 'source_address', + 'source_address_exclude', + 'source_port', + 'translated', # obj attribute, not keyword + 'verbatim', + ]) + # Generators should redefine this in subclass as optional support is added + _OPTIONAL_SUPPORTED_KEYWORDS = set([]) + + # Abbreviation table used to automatically abbreviate terms that exceed + # specified limit. We use uppercase for abbreviations to distinguish + # from lowercase names. This is order list - we try the ones in the + # top of the list before the ones later in the list. Prefer clear + # or very-space-saving abbreviations by putting them early in the + # list. Abbreviations may be regular expressions or fixed terms; + # prefer fixed terms unless there's a clear benefit to regular + # expressions. + _ABBREVIATION_TABLE = [ + ('bogons', 'BGN'), + ('bogon', 'BGN'), + ('reserved', 'RSV'), + ('rfc1918', 'PRV'), + ('rfc-1918', 'PRV'), + ('internet', 'EXT'), + ('global', 'GBL'), + ('internal', 'INT'), + ('customer', 'CUST'), + ('google', 'GOOG'), + ('ballmer', 'ASS'), + ('microsoft', 'LOL'), + ('china', 'BAN'), + ('border', 'BDR'), + ('service', 'SVC'), + ('router', 'RTR'), + ('transit', 'TRNS'), + ('experiment', 'EXP'), + ('established', 'EST'), + ('unreachable', 'UNR'), + ('fragment', 'FRG'), + ('accept', 'OK'), + ('discard', 'DSC'), + ('reject', 'REJ'), + ('replies', 'ACK'), + ('request', 'REQ'), + ] + # Maximum term length. Can be overriden by generator to enforce + # platform specific restrictions. + _TERM_MAX_LENGTH = 62 + + def __init__(self, pol, exp_info): + """Initialise an ACLGenerator. Store policy structure for processing.""" + object.__init__(self) + + # The default list of valid keyword tokens for generators + self._VALID_KEYWORDS = self._REQUIRED_KEYWORDS.union( + self._OPTIONAL_SUPPORTED_KEYWORDS) + + self.policy = pol + + for header, terms in pol.filters: + if self._PLATFORM in header.platforms: + # Verify valid keywords + # error on unsupported optional keywords that could result + # in dangerous or unexpected results + for term in terms: + # Only verify optional keywords if the term is active on the platform. + err = [] + if term.platform: + if self._PLATFORM not in term.platform: + continue + if term.platform_exclude: + if self._PLATFORM in term.platform_exclude: + continue + for el, val in term.__dict__.items(): + # Private attributes do not need to be valid keywords. + if (val and el not in self._VALID_KEYWORDS + and not el.startswith('flatten')): + err.append(el) + if err: + raise UnsupportedFilterError('%s %s %s %s %s %s' % ('\n', term.name, + 'unsupported optional keywords for target', self._PLATFORM, + 'in policy:', ' '.join(err))) + continue + + self._TranslatePolicy(pol, exp_info) + + def _TranslatePolicy(self, pol, exp_info): + """Translate policy contents to platform specific data structures.""" + raise Error('%s does not implement _TranslatePolicies()' % self._PLATFORM) + + def FixHighPorts(self, term, af='inet', all_protocols_stateful=False): + """Evaluate protocol and ports of term, return sane version of term.""" + mod = term + + # Determine which protocols this term applies to. + if term.protocol: + protocols = set(term.protocol) + else: + protocols = set((self._DEFAULT_PROTOCOL,)) + + # Check that the address family matches the protocols. + if not af in self._SUPPORTED_AF: + raise UnsupportedAF('\nAddress family %s, found in %s, ' + 'unsupported by %s' % (af, term.name, self._PLATFORM)) + if af in self._FILTER_BLACKLIST: + unsupported_protocols = self._FILTER_BLACKLIST[af].intersection(protocols) + if unsupported_protocols: + raise UnsupportedFilter('\n%s targets do not support protocol(s) %s ' + 'with address family %s (in %s)' % + (self._PLATFORM, unsupported_protocols, + af, term.name)) + + # Many renders expect high ports for terms with the established option. + for opt in [str(x) for x in term.option]: + if opt.find('established') == 0: + unstateful_protocols = protocols.difference(set(('tcp', 'udp'))) + if not unstateful_protocols: + # TCP/UDP: add in high ports then collapse to eliminate overlaps. + mod = copy.deepcopy(term) + mod.destination_port.append((1024, 65535)) + mod.destination_port = mod.CollapsePortList(mod.destination_port) + elif not all_protocols_stateful: + errmsg = 'Established option supplied with inappropriate protocol(s)' + raise EstablishedError('%s %s %s %s' % + (errmsg, unstateful_protocols, + 'in term', term.name)) + break + + return mod + + def FixTermLength(self, term_name, abbreviate=False, truncate=False): + """Return a term name which is equal or shorter than _TERM_MAX_LENGTH. + + New term is obtained in two steps. First, if allowed, automatic + abbreviation is performed using hardcoded abbreviation table. Second, + if allowed, term name is truncated to specified limit. + + Args: + term_name: Name to abbreviate if necessary. + abbreviate: Whether to allow abbreviations to shorten the length. + truncate: Whether to allow truncation to shorten the length. + Returns: + A string based on term_name, that is equal or shorter than + _TERM_MAX_LENGTH abbreviated and truncated as necessary. + Raises: + TermNameTooLongError: term_name cannot be abbreviated + to be shorter than _TERM_MAX_LENGTH, or truncation is disabled. + """ + new_term = term_name + if abbreviate: + for word, abbrev in self._ABBREVIATION_TABLE: + if len(new_term) <= self._TERM_MAX_LENGTH: + return new_term + new_term = re.sub(word, abbrev, new_term) + if truncate: + new_term = new_term[:self._TERM_MAX_LENGTH] + if len(new_term) <= self._TERM_MAX_LENGTH: + return new_term + raise TermNameTooLongError('Term %s (originally %s) is ' + 'too long. Limit is %d characters (vs. %d) ' + 'and no abbreviations remain or abbreviations ' + 'disabled.' % + (new_term, term_name, + self._TERM_MAX_LENGTH, + len(new_term))) + + +def AddRepositoryTags(prefix=''): + """Add repository tagging into the output. + + Args: + prefix: comment delimiter, if needed, to appear before tags + Returns: + list of text lines containing revision data + """ + tags = [] + p4_id = '%sId:%s' % ('$', '$') + p4_date = '%sDate:%s' % ('$', '$') + tags.append('%s%s' % (prefix, p4_id)) + tags.append('%s%s' % (prefix, p4_date)) + return tags + + +def WrapWords(textlist, size, joiner='\n'): + """Insert breaks into the listed strings at specified width. + + Args: + textlist: a list of text strings + size: width of reformated strings + joiner: text to insert at break. eg. '\n ' to add an indent. + Returns: + list of strings + """ + # \S*? is a non greedy match to collect words of len > size + # .{1,%d} collects words and spaces up to size in length. + # (?:\s|\Z) ensures that we break on spaces or at end of string. + rval = [] + linelength_re = re.compile(r'(\S*?.{1,%d}(?:\s|\Z))' % size) + for index in range(len(textlist)): + if len(textlist[index]) > size: + # insert joiner into the string at appropriate places. + textlist[index] = joiner.join(linelength_re.findall(textlist[index])) + # avoid empty comment lines + rval.extend(x.strip() for x in textlist[index].strip().split(joiner) if x) + return rval |