diff --git a/NOTES.rst b/NOTES.rst index 3887802cd0..571b025809 100644 --- a/NOTES.rst +++ b/NOTES.rst @@ -16,6 +16,30 @@ Dependency changes .. IMPORTANT:: NAV 5.10 requires PostgreSQL to be at least version *11*. +New dependencies +~~~~~~~~~~~~~~~~ + +Dependencies to these Python modules have been added in order to support +communicating with Palo Alto firewall APIs: + +* :mod:`PyOpenSSL` (``==23.3.0``) +* :mod:`service-identity` (``==21.1.0``) + +Support for fetching ARP cache data from Palo Alto firewalls +------------------------------------------------------------ + +Palo Alto firewalls do support SNMP. They do not, however, support fetching +ARP cache data using SNMP. A new ipdevpoll plugin, ``paloaltoarp``, has been +added to fetch ARP cache data using the REST API built in to these firewall +products. + +Access credentials for Palo Alto firewalls need to be configured in +:file:`ipdevpoll.conf`, but a later NAV release should move to providing +management profiles also for this. + +Please read more in :doc:`the ipdevpoll reference documentation +` for configuration details. + NAV 5.9 ======= diff --git a/changelog.d/2613.added.md b/changelog.d/2613.added.md new file mode 100644 index 0000000000..063d0021e2 --- /dev/null +++ b/changelog.d/2613.added.md @@ -0,0 +1 @@ +New ipdevpoll plugin to fetch ARP cache data from Palo Alto firewall APIs diff --git a/doc/reference/ipdevpoll.rst b/doc/reference/ipdevpoll.rst index 4b36aa9d85..eafa417a9e 100644 --- a/doc/reference/ipdevpoll.rst +++ b/doc/reference/ipdevpoll.rst @@ -106,6 +106,36 @@ Section [linkstate] The value ``any`` will generate alerts for all link state changes, but **this is not recommended** for performance reasons. +Section [paloaltoarp] +--------------------- + +This section configures the Palo Alto ARP plugin. Palo Alto firewalls do +support SNMP. They do not, however, support fetching ARP cache data using +SNMP. This plugin enables fetching ARP records from Palo Alto firewalls using +their built-in REST API. + +Currently, there is no management profile type for this type of REST APIs, so +credentials to access a Palo Alto firewall's API must be configured in this +section. + +If you have a Palo Alto firewall named ``example-fw.example.org``, with an IP +address of ``10.0.42.42`` and a secret API token of +``762e87e0ec051a1c5211a08dd48e7a93720eee63``, you can configure this in this +section by adding:: + + example-fw.example.org = 762e87e0ec051a1c5211a08dd48e7a93720eee63 + +Or, alternatively:: + + 10.0.42.42 = 762e87e0ec051a1c5211a08dd48e7a93720eee63 + + +.. warning:: The Palo Alto ARP plugin does not currently verify TLS + certificates when accessing a Palo Alto API. This will be changed + at a later date, but if it worries you, you should not use the + plugin yet. + + Job sections ------------ diff --git a/python/nav/etc/ipdevpoll.conf b/python/nav/etc/ipdevpoll.conf index 1e78bc2d6a..1b10c7eac3 100644 --- a/python/nav/etc/ipdevpoll.conf +++ b/python/nav/etc/ipdevpoll.conf @@ -79,6 +79,7 @@ linkaggregate= bgp= poe= juniperalarm= +paloaltoarp= [job_inventory] # @@ -132,9 +133,9 @@ description: Checks for changes in the reverse DNS records of devices interval: 30m intensity: 0 plugins: - arp + arp paloaltoarp description: - The ip2mac job logs IP to MAC address mappings from routers + The ip2mac job logs IP to MAC address mappings from routers and firewalls (i.e. from IPv4 ARP and IPv6 Neighbor caches) @@ -225,6 +226,15 @@ filter = topology # using the staticroutes plugins. Value is a number of seconds between requests. #throttle-delay=0.0 +[paloaltoarp] +# Until a management profile type for (Palo Alto) REST API credentials +# exist in NAV, this section is used to configure API tokens/keys per +# Palo Alto firewall. Identify each Palo Alto firewall with either its +# NAV sysname or management IP address: + +#hostname = secret-API-key +#ip = another-secret-API-key + [sensors] # A space-separated list of Python modules to load into ipdevpoll as the # sensors plugin is loaded. An asterisk suffix will cause all modules in that diff --git a/python/nav/ipdevpoll/plugins/paloaltoarp.py b/python/nav/ipdevpoll/plugins/paloaltoarp.py new file mode 100644 index 0000000000..5d5bd0dc06 --- /dev/null +++ b/python/nav/ipdevpoll/plugins/paloaltoarp.py @@ -0,0 +1,150 @@ +# +# Copyright (C) 2023, 2024 University of Tromsø +# Copyright (C) 2024 Sikt +# +# This file is part of Network Administration Visualized (NAV). +# +# NAV is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License version 3 as published by the Free +# Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. You should have received a copy of the GNU General Public +# License along with NAV. If not, see . +# + +"""ipdevpoll plugin for fetching arp mappings from Palo Alto firewalls + +Add [paloaltoarp] section to ipdevpoll.conf +add hostname = key to [paloaltoarp] section +for example: +[paloaltoarp] +10.0.0.0 = abcdefghijklmnopqrstuvwxyz1234567890 + +""" + +import xml.etree.ElementTree as ET +from typing import Dict + +from IPy import IP +from twisted.internet import defer, reactor, ssl +from twisted.internet.defer import returnValue +from twisted.web import client +from twisted.web.client import Agent +from twisted.web.http_headers import Headers + +from nav import buildconf +from nav.ipdevpoll.plugins.arp import Arp + + +class PaloaltoArp(Arp): + configured_devices: Dict[str, str] = {} + + @classmethod + def on_plugin_load(cls): + """Loads the list of PaloAlto access keys from ipdevpoll.conf into the plugin + class instance, so that `can_handle` will be able to answer which devices + this plugin can run for. + """ + from nav.ipdevpoll.config import ipdevpoll_conf + + cls._logger.debug("loading paloaltoarp configuration") + if 'paloaltoarp' not in ipdevpoll_conf: + cls._logger.debug("PaloaltoArp config section NOT found") + return + cls._logger.debug("PaloaltoArp config section found") + cls.configured_devices = dict(ipdevpoll_conf['paloaltoarp']) + + @classmethod + def can_handle(cls, netbox): + """Return True if this plugin can handle the given netbox.""" + return ( + netbox.sysname in cls.configured_devices + or str(netbox.ip) in cls.configured_devices + ) + + @defer.inlineCallbacks + def handle(self): + """Handle plugin business, return a deferred.""" + + api_key = self.configured_devices.get( + str(self.netbox.ip), self.configured_devices.get(self.netbox.sysname, "") + ) + self._logger.debug("Collecting IP/MAC mappings for Paloalto device") + + mappings = yield self._get_paloalto_arp_mappings(self.netbox.ip, api_key) + if mappings is None: + self._logger.info("No mappings found for Paloalto device") + returnValue(None) + + yield self._process_data(mappings) + + returnValue(None) + + @defer.inlineCallbacks + def _get_paloalto_arp_mappings(self, address: str, key: str): + """Get mappings from Paloalto device""" + + arptable = yield self._do_request(address, key) + if arptable is None: + returnValue(None) + + # process arpdata into an array of mappings + mappings = parse_arp(arptable.decode('utf-8')) + returnValue(mappings) + + @defer.inlineCallbacks + def _do_request(self, address: str, key: str): + """Make request to Paloalto device""" + + class SslPolicy(client.BrowserLikePolicyForHTTPS): + def creatorForNetloc(self, hostname, port): + return ssl.CertificateOptions(verify=False) + + url = f"https://{address}/api/?type=op&cmd=&key={key}" + self._logger.debug("making request: %s", url) + + agent = Agent(reactor, contextFactory=SslPolicy()) + + try: + response = yield agent.request( + b'GET', + url.encode('utf-8'), + Headers( + {'User-Agent': [f'NAV/PaloaltoArp; version {buildconf.VERSION}']} + ), + None, + ) + except Exception: # noqa + self._logger.exception( + "Error when talking to PaloAlto API. " + "Make sure the device is reachable and the API key is correct." + ) + returnValue(None) + + response = yield client.readBody(response) + returnValue(response) + + +def parse_arp(arp): + """ + Create mappings from arp table + xml.etree.ElementTree is considered insecure: https://docs.python.org/3/library/xml.html#xml-vulnerabilities + However, since we are not parsing untrusted data, this should not be a problem. + """ + + arps = [] + + root = ET.fromstring(arp) + entries = root[0][4] + for entry in entries: + status = entry[0].text + ip = entry[1].text + mac = entry[2].text + if status.strip() != "i": + if mac != "(incomplete)": + arps.append(('ifindex', IP(ip), mac)) + + return arps diff --git a/requirements/base.txt b/requirements/base.txt index 4af7fd589e..e32753d118 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -39,3 +39,12 @@ backports.zoneinfo ; python_version < '3.9' importlib_metadata ; python_version < '3.8' importlib_resources ; python_version < '3.9' git+https://github.com/Uninett/drf-oidc-auth@v4.0#egg=drf-oidc-auth + +# The following modules are really sub-requirements of Twisted, not of +# NAV directly. They may be optional from Twisted's point of view, +# but they are required for parts of the Twisted library that NAV uses: +# +# PyOpenSSL is required for TLS verification during PaloAlto API GET operations +PyOpenSSL==23.3.0 +# service-identity is required to make TLS communication libraries shut up about potential MITM attacks +service-identity==21.1.0 diff --git a/tests/unittests/ipdevpoll/plugins_paloaltoarp_test.py b/tests/unittests/ipdevpoll/plugins_paloaltoarp_test.py new file mode 100644 index 0000000000..f906a943dc --- /dev/null +++ b/tests/unittests/ipdevpoll/plugins_paloaltoarp_test.py @@ -0,0 +1,109 @@ +from unittest.mock import patch, Mock + +from IPy import IP +from nav.ipdevpoll.plugins.paloaltoarp import PaloaltoArp, parse_arp +from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks, succeed +from twisted.web.client import Agent, Response + +mock_data = b''' + + + 132000 + 3 + 1800 + s3dp1 + + + s + 192.168.0.1 + 00:00:00:00:00:01 + 100 + ae2 + ae2 + + + e + 192.168.0.2 + 00:00:00:00:00:02 + 200 + ae2 + ae2 + + + c + 192.168.0.3 + 00:00:00:00:00:03 + 300 + ae3.61 + ae3 + + + i + 192.168.0.4 + 00:00:00:00:00:04 + 400 + ae3.61 + ae3 + + + + + ''' + + +def test_parse_mappings(): + assert parse_arp(mock_data) == [ + ('ifindex', IP('192.168.0.1'), '00:00:00:00:00:01'), + ('ifindex', IP('192.168.0.2'), '00:00:00:00:00:02'), + ('ifindex', IP('192.168.0.3'), '00:00:00:00:00:03'), + ] + + +@inlineCallbacks +def test_get_mappings(): + # Mocking the __init__ method + with patch.object(PaloaltoArp, "__init__", lambda x: None): + instance = PaloaltoArp() + instance.config = {'paloaltoarp': {'abcdefghijklmnop': '0.0.0.0'}} + + # Mocking _do_request to return the mock_data when called + with patch.object( + PaloaltoArp, "_do_request", return_value=defer.succeed(mock_data) + ): + mappings = yield instance._get_paloalto_arp_mappings( + "0.0.0.0", "abcdefghijklmnop" + ) + + assert mappings == [ + ('ifindex', IP('192.168.0.1'), '00:00:00:00:00:01'), + ('ifindex', IP('192.168.0.2'), '00:00:00:00:00:02'), + ('ifindex', IP('192.168.0.3'), '00:00:00:00:00:03'), + ] + + +@inlineCallbacks +def test_do_request(): + mock_response = Mock(spec=Response) + mock_agent = Mock(spec=Agent) + mock_agent.request.return_value = succeed(mock_response) + + with patch( + 'nav.ipdevpoll.plugins.paloaltoarp.Agent', return_value=mock_agent + ), patch('twisted.web.client.readBody', return_value="test content"): + mock_address = "paloalto.example.org" + mock_key = "secret" + + mock_netbox = Mock(sysname=mock_address, ip="127.0.0.1") + + plugin = PaloaltoArp(netbox=mock_netbox, agent=Mock(), containers=Mock()) + result = yield plugin._do_request(mock_address, mock_key) + + expected_url = f"https://{mock_address}/api/?type=op&cmd=&key={mock_key}".encode( + "utf-8" + ) + mock_agent.request.assert_called() + args, kwargs = mock_agent.request.call_args + assert expected_url in args + + assert result == "test content"