diff options
Diffstat (limited to 'lib/juniper.py')
-rw-r--r-- | lib/juniper.py | 727 |
1 files changed, 727 insertions, 0 deletions
diff --git a/lib/juniper.py b/lib/juniper.py new file mode 100644 index 0000000..f793f34 --- /dev/null +++ b/lib/juniper.py @@ -0,0 +1,727 @@ +#!/usr/bin/python +# +# 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. +# + +__author__ = ['pmoody@google.com (Peter Moody)', + 'watson@google.com (Tony Watson)'] + + +import datetime +import logging + +import aclgenerator +import nacaddr + + +# generic error class +class Error(Exception): + pass + + +class JuniperTermPortProtocolError(Error): + pass + + +class TcpEstablishedWithNonTcp(Error): + pass + + +class JuniperDuplicateTermError(Error): + pass + + +class UnsupportedFilterError(Error): + pass + + +class PrecedenceError(Error): + pass + + +class JuniperIndentationError(Error): + pass + + +class Config(object): + """Config allows a configuration to be assembled easily. + + Configurations are automatically indented following Juniper's style. + A textual representation of the config can be extracted with str(). + + Attributes: + indent: The number of leading spaces on the current line. + tabstop: The number of spaces to indent for a new level. + """ + + def __init__(self, indent=0, tabstop=4): + self.indent = indent + self._initial_indent = indent + self.tabstop = tabstop + self.lines = [] + + def __str__(self): + if self.indent != self._initial_indent: + raise JuniperIndentationError( + 'Expected indent %d but got %d' % (self._initial_indent, self.indent)) + return '\n'.join(self.lines) + + def Append(self, line, verbatim=False): + """Append one line to the configuration. + + Args: + line: The string to append to the config. + verbatim: append line without adjusting indentation. Default False. + Raises: + JuniperIndentationError: If the indentation would be further left + than the initial indent. e.g. too many close braces. + """ + if verbatim: + self.lines.append(line) + return + + if line.endswith('}'): + self.indent -= self.tabstop + if self.indent < self._initial_indent: + raise JuniperIndentationError('Too many close braces.') + spaces = ' ' * self.indent + self.lines.append(spaces + line.strip()) + if line.endswith(' {'): + self.indent += self.tabstop + + +class Term(aclgenerator.Term): + """Representation of an individual Juniper term. + + This is mostly useful for the __str__() method. + + Args: + term: policy.Term object + term_type: the address family for the term, one of "inet", "inet6", + or "bridge" + """ + _DEFAULT_INDENT = 12 + _ACTIONS = {'accept': 'accept', + 'deny': 'discard', + 'reject': 'reject', + 'next': 'next term', + 'reject-with-tcp-rst': 'reject tcp-reset'} + + # the following lookup table is used to map between the various types of + # filters the juniper generator can render. As new differences are + # encountered, they should be added to this table. Accessing members + # of this table looks like: + # self._TERM_TYPE('inet').get('saddr') -> 'source-address' + # + # it's critical that the members of each filter type be the same, that is + # to say that if _TERM_TYPE.get('inet').get('foo') returns something, + # _TERM_TYPE.get('inet6').get('foo') must return the inet6 equivalent. + _TERM_TYPE = {'inet': {'addr': 'address', + 'saddr': 'source-address', + 'daddr': 'destination-address', + 'protocol': 'protocol', + 'protocol-except': 'protocol-except', + 'tcp-est': 'tcp-established'}, + 'inet6': {'addr': 'address', + 'saddr': 'source-address', + 'daddr': 'destination-address', + 'protocol': 'next-header', + 'protocol-except': 'next-header-except', + 'tcp-est': 'tcp-established'}, + 'bridge': {'addr': 'ip-address', + 'saddr': 'ip-source-address', + 'daddr': 'ip-destination-address', + 'protocol': 'ip-protocol', + 'protocol-except': 'ip-protocol-except', + 'tcp-est': 'tcp-flags "(ack|rst)"'}} + + def __init__(self, term, term_type): + self.term = term + self.term_type = term_type + + if term_type not in self._TERM_TYPE: + raise ValueError('Unknown Filter Type: %s' % term_type) + + # some options need to modify the actions + self.extra_actions = [] + + # TODO(pmoody): get rid of all of the default string concatenation here. + # eg, indent(8) + 'foo;' -> '%s%s;' % (indent(8), 'foo'). pyglint likes this + # more. + def __str__(self): + # Verify platform specific terms. Skip whole term if platform does not + # match. + if self.term.platform: + if 'juniper' not in self.term.platform: + return '' + if self.term.platform_exclude: + if 'juniper' in self.term.platform_exclude: + return '' + + config = Config(indent=self._DEFAULT_INDENT) + from_str = [] + + # Don't render icmpv6 protocol terms under inet, or icmp under inet6 + if ((self.term_type == 'inet6' and 'icmp' in self.term.protocol) or + (self.term_type == 'inet' and 'icmpv6' in self.term.protocol)): + config.Append('/* Term %s' % self.term.name) + config.Append('** not rendered due to protocol/AF mismatch.') + config.Append('*/') + return str(config) + + # comment + # this deals just fine with multi line comments, but we could probably + # output them a little cleaner; do things like make sure the + # len(output) < 80, etc. + if self.term.owner: + self.term.comment.append('Owner: %s' % self.term.owner) + if self.term.comment: + config.Append('/*') + for comment in self.term.comment: + for line in comment.split('\n'): + config.Append('** ' + line) + config.Append('*/') + + # Term verbatim output - this will skip over normal term creation + # code. Warning generated from policy.py if appropriate. + if self.term.verbatim: + for next_term in self.term.verbatim: + if next_term.value[0] == 'juniper': + config.Append(str(next_term.value[1]), verbatim=True) + return str(config) + + # Helper for per-address-family keywords. + family_keywords = self._TERM_TYPE.get(self.term_type) + + # option + # this is going to be a little ugly b/c there are a few little messed + # up options we can deal with. + if self.term.option: + for opt in [str(x) for x in self.term.option]: + # there should be a better way to search the array of protocols + if opt.startswith('sample'): + self.extra_actions.append('sample') + + # only append tcp-established for option established when + # tcp is the only protocol, otherwise other protos break on juniper + elif opt.startswith('established'): + if self.term.protocol == ['tcp']: + if 'tcp-established;' not in from_str: + from_str.append(family_keywords['tcp-est'] + ';') + + # if tcp-established specified, but more than just tcp is included + # in the protocols, raise an error + elif opt.startswith('tcp-established'): + flag = family_keywords['tcp-est'] + ';' + if self.term.protocol == ['tcp']: + if flag not in from_str: + from_str.append(flag) + else: + raise TcpEstablishedWithNonTcp( + 'tcp-established can only be used with tcp protocol in term %s' + % self.term.name) + elif opt.startswith('rst'): + from_str.append('tcp-flags "rst";') + elif opt.startswith('initial') and 'tcp' in self.term.protocol: + from_str.append('tcp-initial;') + elif opt.startswith('first-fragment'): + from_str.append('first-fragment;') + + # we don't have a special way of dealing with this, so we output it and + # hope the user knows what they're doing. + else: + from_str.append('%s;' % opt) + + # term name + config.Append('term %s {' % self.term.name) + + # a default action term doesn't have any from { clause + has_match_criteria = (self.term.address or + self.term.destination_address or + self.term.destination_prefix or + self.term.destination_port or + self.term.precedence or + self.term.protocol or + self.term.protocol_except or + self.term.port or + self.term.source_address or + self.term.source_prefix or + self.term.source_port or + self.term.ether_type or + self.term.traffic_type) + + if has_match_criteria: + config.Append('from {') + + term_af = self.AF_MAP.get(self.term_type) + + # address + address = self.term.GetAddressOfVersion('address', term_af) + if address: + config.Append('%s {' % family_keywords['addr']) + for addr in address: + config.Append('%s;%s' % (addr, self._Comment(addr))) + config.Append('}') + elif self.term.address: + logging.warn(self.NO_AF_LOG_FORMAT.substitute(term=self.term.name, + af=self.term_type)) + return '' + + # source address + source_address, source_address_exclude = self._MinimizePrefixes( + self.term.GetAddressOfVersion('source_address', term_af), + self.term.GetAddressOfVersion('source_address_exclude', term_af)) + + if source_address: + config.Append('%s {' % family_keywords['saddr']) + for addr in source_address: + config.Append('%s;%s' % (addr, self._Comment(addr))) + for addr in source_address_exclude: + config.Append('%s except;%s' % ( + addr, self._Comment(addr, exclude=True))) + config.Append('}') + elif self.term.source_address: + logging.warn(self.NO_AF_LOG_FORMAT.substitute(term=self.term.name, + direction='source', + af=self.term_type)) + return '' + + # destination address + destination_address, destination_address_exclude = self._MinimizePrefixes( + self.term.GetAddressOfVersion('destination_address', term_af), + self.term.GetAddressOfVersion('destination_address_exclude', term_af)) + + if destination_address: + config.Append('%s {' % family_keywords['daddr']) + for addr in destination_address: + config.Append('%s;%s' % (addr, self._Comment(addr))) + for addr in destination_address_exclude: + config.Append('%s except;%s' % ( + addr, self._Comment(addr, exclude=True))) + config.Append('}') + elif self.term.destination_address: + logging.warn(self.NO_AF_LOG_FORMAT.substitute(term=self.term.name, + direction='destination', + af=self.term_type)) + return '' + + # source prefix list + if self.term.source_prefix: + config.Append('source-prefix-list {') + for pfx in self.term.source_prefix: + config.Append(pfx + ';') + config.Append('}') + + # destination prefix list + if self.term.destination_prefix: + config.Append('destination-prefix-list {') + for pfx in self.term.destination_prefix: + config.Append(pfx + ';') + config.Append('}') + + # protocol + if self.term.protocol: + config.Append(family_keywords['protocol'] + + ' ' + self._Group(self.term.protocol)) + + # protocol + if self.term.protocol_except: + config.Append(family_keywords['protocol-except'] + ' ' + + self._Group(self.term.protocol_except)) + + # port + if self.term.port: + config.Append('port %s' % self._Group(self.term.port)) + + # source port + if self.term.source_port: + config.Append('source-port %s' % self._Group(self.term.source_port)) + + # destination port + if self.term.destination_port: + config.Append('destination-port %s' % + self._Group(self.term.destination_port)) + + # append any options beloging in the from {} section + for next_str in from_str: + config.Append(next_str) + + # packet length + if self.term.packet_length: + config.Append('packet-length %s;' % self.term.packet_length) + + # fragment offset + if self.term.fragment_offset: + config.Append('fragment-offset %s;' % self.term.fragment_offset) + + # icmp-types + icmp_types = [''] + if self.term.icmp_type: + icmp_types = self.NormalizeIcmpTypes(self.term.icmp_type, + self.term.protocol, self.term_type) + if icmp_types != ['']: + config.Append('icmp-type %s' % self._Group(icmp_types)) + + if self.term.ether_type: + config.Append('ether-type %s' % + self._Group(self.term.ether_type)) + + if self.term.traffic_type: + config.Append('traffic-type %s' % + self._Group(self.term.traffic_type)) + + if self.term.precedence: + # precedence may be a single integer, or a space separated list + policy_precedences = set() + # precedence values may only be 0 through 7 + for precedence in self.term.precedence: + if int(precedence) in range(0, 8): + policy_precedences.add(precedence) + else: + raise PrecedenceError('Precedence value %s is out of bounds in %s' % + (precedence, self.term.name)) + config.Append('precedence %s' % self._Group(sorted(policy_precedences))) + + config.Append('}') # end from { ... } + + #### + # ACTIONS go below here + #### + config.Append('then {') + # logging + if self.term.logging: + for log_target in self.term.logging: + if str(log_target) == 'local': + config.Append('log;') + else: + config.Append('syslog;') + + if self.term.routing_instance: + config.Append('routing-instance %s;' % self.term.routing_instance) + + if self.term.counter: + config.Append('count %s;' % self.term.counter) + + if self.term.policer: + config.Append('policer %s;' % self.term.policer) + + if self.term.qos: + config.Append('forwarding-class %s;' % self.term.qos) + + if self.term.loss_priority: + config.Append('loss-priority %s;' % self.term.loss_priority) + + for action in self.extra_actions: + config.Append(action + ';') + + # If there is a routing-instance defined, skip reject/accept/etc actions. + if not self.term.routing_instance: + for action in self.term.action: + config.Append(self._ACTIONS.get(action) + ';') + + config.Append('}') # end then{...} + config.Append('}') # end term accept-foo-to-bar { ... } + + return str(config) + + def _MinimizePrefixes(self, include, exclude): + """Calculate a minimal set of prefixes for Juniper match conditions. + + Args: + include: Iterable of nacaddr objects, prefixes to match. + exclude: Iterable of nacaddr objects, prefixes to exclude. + Returns: + A tuple (I,E) where I and E are lists containing the minimized + versions of include and exclude, respectively. The order + of each input list is preserved. + """ + # Remove any included prefixes that have EXACT matches in the + # excluded list. Excluded prefixes take precedence on the router + # regardless of the order in which the include/exclude are applied. + exclude_set = set(exclude) + include_result = [ip for ip in include if ip not in exclude_set] + + # Every address match condition on a Juniper firewall filter + # contains an implicit "0/0 except" or "0::0/0 except". If an + # excluded prefix is not contained within any less-specific prefix + # in the included set, we can elide it. In other words, if the + # next-less-specific prefix is the implicit "default except", + # there is no need to configure the more specific "except". + # + # TODO(kbrint): this could be made more efficient with a Patricia trie. + exclude_result = [] + for exclude_prefix in exclude: + for include_prefix in include_result: + if exclude_prefix in include_prefix: + exclude_result.append(exclude_prefix) + break + + return include_result, exclude_result + + def _Comment(self, addr, exclude=False, line_length=132): + """Returns address comment field if it exists. + + Args: + addr: nacaddr.IPv4 object (?) + exclude: bool - address excludes have different indentations + line_length: integer - this is the length to which a comment will be + truncated, no matter what. ie, a 1000 character comment will be + truncated to line_length, and then split. if 0, the whole comment + is kept. the current default of 132 is somewhat arbitrary. + + Returns: + string + + Notes: + This method tries to intelligently split long comments up. if we've + managed to summarize 4 /32's into a /30, each with a nacaddr text field + of something like 'foobar N', normal concatination would make the + resulting rendered comment look in mondrian like + + source-address { + ... + 1.1.1.0/30; /* foobar1, foobar2, foobar3, foo + bar4 */ + + b/c of the line splitting at 80 chars. this method will split the + comments at word breaks and make the previous example look like + + source-address { + .... + 1.1.1.0/30; /* foobar1, foobar2, foobar3, + ** foobar4 */ + much cleaner. + """ + rval = [] + # indentation, for multi-line comments, ensures that subsquent lines + # are correctly alligned with the first line of the comment. + indentation = 0 + if exclude: + # len('1.1.1.1/32 except;') == 21 + indentation = 21 + self._DEFAULT_INDENT + len(str(addr)) + else: + # len('1.1.1.1/32;') == 14 + indentation = 14 + self._DEFAULT_INDENT + len(str(addr)) + + # length_eol is the width of the line; b/c of the addition of the space + # and the /* characters, it needs to be a little less than the actual width + # to keep from wrapping + length_eol = 77 - indentation + + if isinstance(addr, (nacaddr.IPv4, nacaddr.IPv6)): + if addr.text: + + if line_length == 0: + # line_length of 0 means that we don't want to truncate the comment. + line_length = len(addr.text) + + # There should never be a /* or */, but be safe and ignore those + # comments + if addr.text.find('/*') >= 0 or addr.text.find('*/') >= 0: + logging.debug('Malformed comment [%s] ignoring', addr.text) + else: + + text = addr.text[:line_length] + + comment = ' /*' + while text: + # split the line + if len(text) > length_eol: + new_length_eol = text[:length_eol].rfind(' ') + if new_length_eol <= 0: + new_length_eol = length_eol + else: + new_length_eol = length_eol + + # what line am I gunna output? + line = comment + ' ' + text[:new_length_eol].strip() + # truncate what's left + text = text[new_length_eol:] + # setup the comment and indentation for the next go-round + comment = ' ' * indentation + '**' + + rval.append(line) + + rval[-1] += ' */' + else: + # should we be paying attention to any other addr type? + logging.debug('Ignoring non IPv4 or IPv6 address: %s', addr) + return '\n'.join(rval) + + def _Group(self, group): + """If 1 item return it, else return [ item1 item2 ]. + + Args: + group: a list. could be a list of strings (protocols) or a list of + tuples (ports) + + Returns: + rval: a string surrounded by '[' and '];' if len(group) > 1 + or with just ';' appended if len(group) == 1 + """ + + def _FormattedGroup(el): + """Return the actual formatting of an individual element. + + Args: + el: either a string (protocol) or a tuple (ports) + + Returns: + string: either the lower()'ed string or the ports, hyphenated + if they're a range, or by itself if it's not. + """ + if isinstance(el, str): + return el.lower() + elif isinstance(el, int): + return str(el) + # type is a tuple below here + elif el[0] == el[1]: + return '%d' % el[0] + else: + return '%d-%d' % (el[0], el[1]) + + if len(group) > 1: + rval = '[ ' + ' '.join([_FormattedGroup(x) for x in group]) + ' ];' + else: + rval = _FormattedGroup(group[0]) + ';' + return rval + + +class Juniper(aclgenerator.ACLGenerator): + """JCL rendering class. + + This class takes a policy object and renders the output into a syntax + which is understood by juniper routers. + + Args: + pol: policy.Policy object + """ + + _PLATFORM = 'juniper' + _DEFAULT_PROTOCOL = 'ip' + _SUPPORTED_AF = set(('inet', 'inet6', 'bridge')) + _SUFFIX = '.jcl' + + _OPTIONAL_SUPPORTED_KEYWORDS = set(['address', + 'counter', + 'destination_prefix', + 'ether_type', + 'expiration', + 'fragment_offset', + 'logging', + 'loss_priority', + 'owner', + 'packet_length', + 'policer', + 'port', + 'precedence', + 'protocol_except', + 'qos', + 'routing_instance', + 'source_prefix', + 'traffic_type', + ]) + + def _TranslatePolicy(self, pol, exp_info): + self.juniper_policies = [] + current_date = datetime.date.today() + exp_info_date = current_date + datetime.timedelta(weeks=exp_info) + + for header, terms in pol.filters: + if self._PLATFORM not in header.platforms: + continue + + filter_options = header.FilterOptions(self._PLATFORM) + filter_name = header.FilterName(self._PLATFORM) + + # Checks if the non-interface-specific option was specified. + # I'm assuming that it will be specified as maximum one time, and + # don't check for more appearances of the word in the options. + interface_specific = 'not-interface-specific' not in filter_options[1:] + + # Remove the option so that it is not confused with a filter type + if not interface_specific: + filter_options.remove('not-interface-specific') + + # default to inet4 filters + filter_type = 'inet' + if len(filter_options) > 1: + filter_type = filter_options[1] + + term_names = set() + new_terms = [] + for term in terms: + term.name = self.FixTermLength(term.name) + if term.name in term_names: + raise JuniperDuplicateTermError('You have multiple terms named: %s' % + term.name) + term_names.add(term.name) + + term = self.FixHighPorts(term, af=filter_type) + if not term: + continue + + if term.expiration: + if term.expiration <= exp_info_date: + logging.info('INFO: Term %s in policy %s expires ' + 'in less than two weeks.', term.name, filter_name) + if term.expiration <= current_date: + logging.warn('WARNING: Term %s in policy %s is expired and ' + 'will not be rendered.', term.name, filter_name) + continue + + new_terms.append(Term(term, filter_type)) + + self.juniper_policies.append((header, filter_name, filter_type, + interface_specific, new_terms)) + + def __str__(self): + config = Config() + + for (header, filter_name, filter_type, interface_specific, terms + ) in self.juniper_policies: + # add the header information + config.Append('firewall {') + config.Append('family %s {' % filter_type) + config.Append('replace:') + config.Append('/*') + + # we want the acl to contain id and date tags, but p4 will expand + # the tags here when we submit the generator, so we have to trick + # p4 into not knowing these words. like taking c-a-n-d-y from a + # baby. + for line in aclgenerator.AddRepositoryTags('** '): + config.Append(line) + config.Append('**') + + for comment in header.comment: + for line in comment.split('\n'): + config.Append('** ' + line) + config.Append('*/') + + config.Append('filter %s {' % filter_name) + if interface_specific: + config.Append('interface-specific;') + + for term in terms: + term_str = str(term) + if term_str: + config.Append(term_str, verbatim=True) + + config.Append('}') # filter { ... } + config.Append('}') # family inet { ... } + config.Append('}') # firewall { ... } + + return str(config) + '\n' |