import logging import re import ipaddress from jnpr.junos import Device from jnpr.junos import exception as EzErrors from lxml import etree import netifaces import requests from requests.auth import HTTPBasicAuth CONFIG_SCHEMA = """<?xml version="1.1" encoding="UTF-8" ?> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> <xs:complexType name="generic-sequence"> <xs:sequence> <xs:any processContents="lax" minOccurs="0" maxOccurs="unbounded"/> </xs:sequence> <xs:anyAttribute processContents="skip" /> </xs:complexType> <!-- NOTE: 'unit' content isn't validated --> <xs:complexType name="juniper-interface"> <xs:sequence> <xs:choice minOccurs="1" maxOccurs="unbounded"> <xs:element name="name" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="description" minOccurs="0" maxOccurs="1"> <xs:complexType> <xs:simpleContent> <xs:extension base="xs:string"> <xs:attribute name="inactive" type="xs:string" /> </xs:extension> </xs:simpleContent> </xs:complexType> </xs:element> <xs:any processContents="lax" minOccurs="0" maxOccurs="unbounded" /> </xs:choice> </xs:sequence> <xs:attribute name="inactive" type="xs:string" /> </xs:complexType> <xs:element name="configuration"> <xs:complexType> <xs:sequence> <xs:choice minOccurs="1" maxOccurs="unbounded"> <xs:element name="version" minOccurs="0" type="xs:string" /> <xs:element name="groups" minOccurs="0" type="generic-sequence" /> <xs:element name="apply-groups" minOccurs="0" type="xs:string" /> <xs:element name="system" minOccurs="0" type="generic-sequence" /> <xs:element name="logical-systems" minOccurs="0" type="generic-sequence" /> <xs:element name="chassis" minOccurs="0" type="generic-sequence" /> <xs:element name="services" minOccurs="0" type="generic-sequence" /> <xs:element name="interfaces" minOccurs="0"> <xs:complexType> <xs:sequence> <xs:choice minOccurs="1" maxOccurs="unbounded"> <xs:element name="apply-groups" minOccurs="0" type="xs:string" /> <xs:element name="interface-range" minOccurs="0" type="generic-sequence" /> <xs:element name="interface" minOccurs="1" maxOccurs="unbounded" type="juniper-interface" /> </xs:choice> </xs:sequence> </xs:complexType> </xs:element> <xs:element name="snmp" minOccurs="0" type="generic-sequence" /> <xs:element name="forwarding-options" minOccurs="0" type="generic-sequence" /> <xs:element name="routing-options" minOccurs="0" type="generic-sequence" /> <xs:element name="protocols" minOccurs="0" type="generic-sequence" /> <xs:element name="policy-options" minOccurs="0" type="generic-sequence" /> <xs:element name="class-of-service" minOccurs="0" type="generic-sequence" /> <xs:element name="firewall" minOccurs="0" type="generic-sequence" /> <xs:element name="routing-instances" minOccurs="0" type="generic-sequence" /> <xs:element name="bridge-domains" minOccurs="0" type="generic-sequence" /> <xs:element name="virtual-chassis" minOccurs="0" type="generic-sequence" /> <xs:element name="vlans" minOccurs="0" type="generic-sequence" /> <xs:element name="comment" minOccurs="0" type="xs:string" /> </xs:choice> </xs:sequence> <xs:attribute name="changed-seconds" type="xs:string" /> <xs:attribute name="changed-localtime" type="xs:string" /> </xs:complexType> </xs:element> </xs:schema> """ # noqa: E501 UNIT_SCHEMA = """<?xml version="1.1" encoding="UTF-8" ?> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> <xs:complexType name="generic-sequence"> <xs:sequence> <xs:any processContents="lax" minOccurs="0" maxOccurs="unbounded"/> </xs:sequence> <xs:anyAttribute processContents="skip" /> </xs:complexType> <xs:element name="unit"> <xs:complexType> <xs:sequence> <xs:element name="name" minOccurs="1" maxOccurs="1" type="xs:int" /> <xs:element name="description" minOccurs="0" maxOccurs="1" type="xs:string" /> <xs:any processContents="lax" minOccurs="0" maxOccurs="unbounded"/> </xs:sequence> <xs:attribute name="inactive" type="xs:string" /> </xs:complexType> </xs:element> </xs:schema> """ # noqa: E501 # elements 'use-nat' and 'fingerprint' were added between # junosspace versions 15.x and 17.x ... hopefully new versions # will also add new elements at the end of the sequence so # that the final xs:any below will suffice to allow validation JUNOSSPACE_DEVICES_SCHEMA = """<?xml version="1.1" encoding="UTF-8" ?> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> <xs:complexType name="junosspace-device"> <xs:sequence> <xs:element name="deviceFamily" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="OSVersion" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="platform" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="serialNumber" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="connectionStatus" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="ipAddr" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="managedStatus" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="device-id" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="web-mgmt" minOccurs="0" maxOccurs="1" type="xs:string" /> <xs:element name="lsys-count" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="hosting-deviceId" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="authentication-status" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="connection-type" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="name" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="domain-id" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="domain-name" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:element name="config-status" minOccurs="1" maxOccurs="1" type="xs:string" /> <xs:any processContents="lax" minOccurs="0" maxOccurs="unbounded" /> </xs:sequence> <xs:attribute name="href" type="xs:string" /> <xs:attribute name="uri" type="xs:string" /> <xs:attribute name="key" type="xs:string" /> </xs:complexType> <xs:element name="devices"> <xs:complexType> <xs:sequence> <xs:element name="device" minOccurs="0" maxOccurs="unbounded" type="junosspace-device" /> </xs:sequence> <xs:attribute name="uri" type="xs:string" /> <xs:attribute name="size" type="xs:string" /> </xs:complexType> </xs:element> </xs:schema> """ # noqa: E501 def _rpc(hostname, ssh): dev = Device( host=hostname, user=ssh['username'], ssh_private_key_file=ssh['private-key']) try: dev.open() except EzErrors.ConnectError as e: raise ConnectionError(str(e)) return dev.rpc def validate_netconf_config(config_doc): logger = logging.getLogger(__name__) def _validate(schema, doc): if schema.validate(doc): return for e in schema.error_log: logger.error("%d.%d: %s" % (e.line, e.column, e.message)) assert False schema_doc = etree.XML(CONFIG_SCHEMA.encode('utf-8')) config_schema = etree.XMLSchema(schema_doc) _validate(config_schema, config_doc) # validate interfaces/interface/unit elements ... schema_doc = etree.XML(UNIT_SCHEMA.encode('utf-8')) unit_schema = etree.XMLSchema(schema_doc) for i in config_doc.xpath('//configuration/interfaces/interface'): for u in i.xpath('./unit'): _validate(unit_schema, u) def load_config(hostname, ssh_params): """ loads netconf data from the router, validates and returns as an lxml etree doc :param hostname: router hostname :param ssh_params: 'ssh' config element(cf. config.py:CONFIG_SCHEMA) :return: """ logger = logging.getLogger(__name__) logger.info("capturing netconf data for '%s'" % hostname) config = _rpc(hostname, ssh_params).get_config() validate_netconf_config(config) return config def list_interfaces(netconf_config): """ generator that parses netconf output and yields a list of interfaces :param netconf_config: xml doc that was generated by load_config :return: """ def _ifc_info(e): # warning: this structure should match the default # returned from routes.classifier.juniper_link_info name = e.find('name') assert name is not None, "expected interface 'name' child element" ifc = { 'name': name.text, 'description': '', 'bundle': [] } description = e.find('description') if description is not None: ifc['description'] = description.text for b in i.iterfind(".//bundle"): ifc['bundle'].append(b.text) ifc['ipv4'] = e.xpath('./family/inet/address/name/text()') ifc['ipv6'] = e.xpath('./family/inet6/address/name/text()') return ifc def _units(base_name, node): for u in node.xpath('./unit'): if u.get('inactive', None) == 'inactive': continue unit_info = _ifc_info(u) unit_info['name'] = "%s.%s" % (base_name, unit_info['name']) yield unit_info for i in netconf_config.xpath('//configuration/interfaces/interface'): info = _ifc_info(i) yield info for u in _units(info['name'], i): yield u for i in netconf_config.xpath( '//configuration/logical-systems/interfaces/interface'): name = i.find('name') assert name is not None, 'expected interface ''name'' child element' for u in _units(name.text, i): yield u def list_bgp_routes(netconf_config): for r in netconf_config.xpath( '//configuration/routing-instances/' 'instance[name/text()="IAS"]/protocols/bgp/' 'group[starts-with(name/text(), "GEANT-IX")]/' 'neighbor'): name = r.find('name') description = r.find('description') local_as = r.find('local-as') if local_as is not None: local_as = local_as.find('as-number') peer_as = r.find('peer-as') yield { 'name': name.text, 'description': description.text, 'as': { 'local': int(local_as.text), 'peer': int(peer_as.text) } } def ix_public_peers(netconf_config): for r in netconf_config.xpath( '//configuration/routing-instances/' 'instance[name/text()="IAS"]/protocols/bgp/' 'group[starts-with(name/text(), "GEANT-IX")]/' 'neighbor'): name = r.find('name') description = r.find('description') local_as = r.find('local-as') if local_as is not None: local_as = local_as.find('as-number') peer_as = r.find('peer-as') yield { 'name': ipaddress.ip_address(name.text).exploded, 'description': description.text, 'as': { 'local': int(local_as.text), 'peer': int(peer_as.text) } } def vpn_rr_peers(netconf_config): for r in netconf_config.xpath( '//configuration/logical-systems[name/text()="VRR"]/' '/protocols/bgp/' 'group[name/text()="VPN-RR" or name/text()="VPN-RR-INTERNAL"]/' 'neighbor'): neighbor = { 'name': ipaddress.ip_address(r.find('name').text).exploded, 'description': r.find('description').text, } peer_as = r.find('peer-as') if peer_as is not None: neighbor['peer-as'] = int(r.find('peer-as').text) yield neighbor def interface_addresses(netconf_config): """ yields a list of all distinct interface addresses :param netconf_config: :return: """ for ifc in list_interfaces(netconf_config): for address in ifc['ipv4'] + ifc['ipv6']: yield { "name": ipaddress.ip_interface(address).ip.exploded, "interface address": address, "interface name": ifc['name'] } # note for enabling vrr data parsing ... # def fetch_vrr_config(hostname, ssh_params): # # commands = [ # _DISABLE_PAGING_COMMAND, # ('show configuration logical-systems ' # 'VRR protocols bgp | display json') # ] # # output = list(ssh_exec_commands(hostname, ssh_params, commands)) # assert len(output) == len(commands) # # return _loads(output[1]) if output[1] else {} # def load_routers_from_junosspace(config): """ query junosspace for configured devices :param config: junosspace config element from app config :return: list of dictionaries, each element of which describes a router """ logger = logging.getLogger(__name__) request_url = config['api'] if not request_url.endswith('/'): request_url += '/' request_url += 'device-management/devices' r = requests.get( request_url, auth=HTTPBasicAuth(config['username'], config['password']), # TODO: seems server doesn't send the full chain # ... add the terena cert locally & reenable cert validateion verify=False ) # TODO: use a proper exception type if r.status_code != 200: logger.error("error response from %r" % request_url) assert False # TODO: use proper exception type devices = etree.fromstring(r.text.encode('utf-8')) schema_doc = etree.XML(JUNOSSPACE_DEVICES_SCHEMA.encode('utf-8')) schema = etree.XMLSchema(schema_doc) if not schema.validate(devices): for e in schema.error_log: logger.error('%d.%d: %s' % (e.line, e.column, e.message)) assert False def _derive_hostname(n): # TODO: ask ops if this name->hostname operation is valid if n.endswith('geant.net'): return n m = re.match(r'^(.*?)(\.re\d+)?$', n) if m: return m.group(1) + '.geant.net' logger.error(f'unrecognized junosspace device name format: "{n}"') return None for d in devices.xpath('//devices/device'): name = d.xpath('./name/text()')[0] yield { "OSVersion": d.xpath('./OSVersion/text()')[0], "platform": d.xpath('./platform/text()')[0], "address": d.xpath('./ipAddr/text()')[0], "name": name, "hostname": _derive_hostname(name) } def local_interfaces( type=netifaces.AF_INET, omit_link_local=True, omit_loopback=True): """ generator yielding IPv4Interface or IPv6Interface objects, depending on the value of type :param type: hopefully AF_INET or AF_INET6 :param omit_link_local: skip v6 fe80* addresses if true :param omit_loopback: skip lo* interfaces if true :return: """ for n in netifaces.interfaces(): if omit_loopback and re.match(r'^lo\d+', n): continue am = netifaces.ifaddresses(n) for a in am.get(type, []): if omit_link_local and a['addr'].startswith('fe80:'): continue m = re.match(r'^(.+?)(%.*)?$', a['addr']) assert m addr = m.group(1) m = re.match(r'.*/(\d+)$', a['netmask']) if m: mask = m.group(1) else: mask = a['netmask'] yield ipaddress.ip_interface('%s/%s' % (addr, mask)) def snmp_community_string(netconf_config): my_addressess = list([i.ip for i in local_interfaces()]) for community in netconf_config.xpath('//configuration/snmp/community'): for subnet in community.xpath('./clients/name/text()'): allowed_network = ipaddress.ip_network(subnet, strict=False) for me in my_addressess: if me in allowed_network: return community.xpath('./name/text()')[0] return None def netconf_changed_timestamp(netconf_config): ''' return the last change timestamp published by the config document :param netconf_config: netconf lxml etree document :return: an epoch timestamp (integer number of seconds) or None ''' for ts in netconf_config.xpath('/configuration/@changed-seconds'): if re.match(r'^\d+$', ts): return int(ts) logger = logging.getLogger(__name__) logger.warning('no valid timestamp found in netconf configuration') return None