Maandelijks archief: januari 2021

Netconf maar dan EasZy

In netwerk land zijn diverse manieren om je netwerk device te beheren. Old-skool via CLI, via SNMP, via een GUI of zelfs een API. Het kan redelijk alle kanten op.

De meest praktische manier in hedendaagse tijd is middels Netconf. Dit communiceert via RPC calls en gebruikt XML voor communicatie standaarden.

Hoewel er zat devices zijn met allemaal hun eigen nukken, netconf praten ze. Alleen niet in een uniforme manier. Dat is op zich wel recht te breien maar dan met een eigen soort ‘API’ in Python. Hoewel daar al packages zijn (zoals Napalm) is het goed om zelf ook eens te kijken hoe je het voor elkaar krijgt om de denkwijze goed te kunnen begrijpen. Vanuit presentaties van een core python developer (R. Hettinger) is de denkwijze dat een Class in Python eigenlijk als een API zou moeten werken. Dat klinkt goed. Waarom zou je meerdere wegen per type device bewandelen als het ook abstracter kan.

Laten we vast stellen dat er een netwerk is met een aantal devices, elke met hun eigen afwijking. Je wilt vanuit jouw programma eigenlijk van al deze devices:

  1. uniform verbinden
  2. data op 1 manier opvragen
  3. data op 1 manier terug krijgen

In dit voorbeeld ga ik dieper in op een Juniper omgeving maar de basis is hetzelfde. Belangrijkste is om de strategie vooraf te bepalen.

De strategie is als volgt:

  1. Uniform verbinden
    • Router? Switch? Firewall? Allen kunnen geclassificeerd worden als ‘JuniperDevice’
      • Parent Class: verbind met device, error afhandeling, context manager
    • Per device type onderscheid maken om specifieke zaken te behandelen
      • Child Class: definieer data bronnen als properties
  2. Data op 1 manier opvragen
    • Interfaces? Mac adressen? Vlans? De methode zou uniek moeten zijn
      • Opvragen via middels Yaml templates
  3. Data op 1 manier terug krijgen
    • Vaste data structuur
      • Teruggeven van XML object

Laten we de parent class gaan starten:

class JuniperDevice:
    """Parent Class to connect in a single way to a netconf-enabled Juniper device."""
    vendor = 'Juniper'

    def __init__(self, **kwargs):

Meer is niet nodig om de platte basis neer te zetten. Maar dit doet nog niets. In de ‘init’ fase moeten o.a. hostname, credentials en meer opgetuigd worden. Ik begin met een 2-tal basis zaken. Een Juniper device, met name EX en SRX willen nog wel eens verschillen in type (ELS of niet). Daarnaast wil ik een hostname hebben, via het keyword argument ‘hostname’:

        self.facts = None
        self.hostname = kwargs.get('hostname', None)
        if not isinstance(self.hostname, str):
            raise TypeError("No hostname provided or not a string")

Hiermee is nog geen verbinding gestart. Dat gaat via de packages vanuit PyEZ (pip install junos-eznc). Dit gaan we invoeren, halen direct de ‘facts’ erbij voor bepaling ELS of niet, en wat error afhandeling. Belangrijk is het via pyez opstarten van de connectie middels de Device instance:

from jnpr.junos import Device
from jnpr.junos import exception
...
    def __init__(self, **kwargs):
        ...
        try:
            self.device = Device(host=self.hostname, user='johndoe', password='my_very_secret')
            self.device.open()
            self.facts = self.device.facts
            try:
                if self.facts['switch_style'] == 'VLAN_L2NG':
                    self.is_els_type = True
            except KeyError:
                raise RuntimeError(f'Can not determine switch_style for {self.hostname}')
        except (exception.ConnectRefusedError, exception.ConnectTimeoutError, exception.ConnectUnknownHostError,
                exception.ProbeError, exception.ConnectError):
            raise ConnectionError

We zouden nu deze class kunnen aanroepen, zoals ‘dev = Juniperdevice()’ maar dat is nog niet alles. Het kan maar wat handigheid inbouwen heeft de voorkeur. Dus we kunnen de class zo opbouwen dat het ook automatisch de connectie sluit. De class zal dus ook een context manager worden. Hiervoor zijn 2 class methodes nodig (of in python: dunder) genaamd ‘enter’ en ‘exit’:

...
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.device.close()
        return True

Laten we naast deze ook een property invoeren in de parent class, voor het gemak. De ‘facts’ hebben we tenslotte al opgehaald, dus kunnen we die ook terug geven (of opgemaakt presenteren, jouw keuze):

    @property
    def get_facts(self):
        return self.facts

Met al deze stukjes is in principe de parent class voor nu klaar. Er zal voor het mooie nog een dunder ‘__repr__’ in mogen en een paar andere zaken maar het gaat om het grotere geheel.

Nu de parent class gedefinieerd is en we op een uniforme manier kunnen verbinden naar een Juniper netconf-enabled device. We zouden in het hoofd programma dus kunnen starten met:

def main():
    with JuniperDevice(hostname='coolswitch') as dev:
        print(dev.facts)

Nu kunnen we de diepte in en specifiekere Juniper devices inrichten. We starten een nieuwe class maar met een inherit van JuniperDevice:

class JuniperSwitch(JuniperDevice):
    """Child Class to present data from a JuniperDevice"""
    type = 'Switch'

Nu zouden we in het hoofd programma dus ook dit kunnen doen:

def main():
    with JuniperSwitch(hostname='coolswitch') as dev:
        print(dev.facts)
        print(f'I am a {dev.vendor} and I am a {dev.type}')

Maar dat is niet alles. We zouden nu dan ook de stap kunnen maken om een RPC call te maken en dat gaan we via een YAML template doen.

Dit doen we voor nu om het behapbaar te houden (niet via automatisch ingeladen .yml files en dergelijke in package modules verstopt) even in de class file zelf.

We voegen een aantal imports toe en plaatsen een YAML template. Daarna maken in de JuniperSwitch class een property aan. Deze property vraagt in dit geval een lijst op van Ethernet Ports op en geeft deze terug.

from jnpr.junos.factory.factory_loader import FactoryLoader
import yaml

etherports_yaml = """
---
EtherPortsTable:
    rpc: get-interface-information
    item: physical-interface
    key: name
    view: EtherPortsTableView

EtherPortsTableView:
    fields:
        name: name
        admin_status: admin-status
        oper_status: oper-status
"""

globals().update(FactoryLoader().load(yaml.load(etherports_yaml, Loader=yaml.FullLoader)))

class JuniperSwitch(JuniperDevice):
    ...
    @property
    def eth_ports_table(self):
        return EtherPortsTable(self.device).get()

Hiermee zouden we dus al kunnen zeggen dat we kunnen verbinden en een overzicht kunnen raadplegen vanuit de switch met ethernet poorten. Het hoofdprogramma zou dan dit kunnen doen:

def main():
    with JuniperSwitch(hostname='coolswitch') as dev:
        print(dev.facts)
        print(f'I am a {dev.vendor} and I am a {dev.type}')
        for port in dev.eth_ports_table:
            print(f'{port.name}')

Het XML object wat vanuit de property method terug komt heeft een aantal velden die we kunnen aanspreken. Deze zijn gelijk aan die in de YAML template. Dus met de ‘for’ loop kunen we dus port.name opvragen maar ook port.oper_status.

Stel dat je een set aan Juniper EX switches hebt die wel en geen ELS type zijn, dan kan je differentiatie maken tussen deze. Enige wat moet is 2 verschillende YAML templates inladen (1 voor ELS, 1 voor normaal) en dan in de property hierop de juiste opvragen en terug geven (even zonder YAML template):

class JuniperSwitch(JuniperDevice):
    ...
    @property
    def mac_table(self):
        if self.is_els_type:
            return ElsEtherSwTable(self.device).get()
        return EtherSwTable(self.device).get()

Een Juniper Router toevoegen met zijn eigen ‘views’ is dus dan redelijk easy. Een extra property toevoegen is een kwestie van een YAML template en terug geven. Je kan dus een soort API maken met alle mogelijke views. Daarna kan je in je hoofd progamma met deze informatie aan de slag.

Het geheel voor nu zou er dus zo uitzien (zonder bovengenoemde mac_table property:

from jnpr.junos import Device
from jnpr.junos import exception
from jnpr.junos.factory.factory_loader import FactoryLoader
import yaml

etherports_yaml = """
---
EtherPortsTable:
    rpc: get-interface-information
    item: physical-interface
    key: name
    view: EtherPortsTableView

EtherPortsTableView:
    fields:
        name: name
        admin_status: admin-status
        oper_status: oper-status
"""

globals().update(FactoryLoader().load(yaml.load(etherports_yaml, Loader=yaml.FullLoader)))


class JuniperDevice:
    """Parent Class to connect in a single way to a netconf-enabled Juniper device."""
    vendor = 'Juniper'

    def __init__(self, **kwargs):
        self.facts = None
        self.hostname = kwargs.get('hostname', None)
        if not isinstance(self.hostname, str):
            raise TypeError("No hostname provided or not a string")
        try:
            self.device = Device(host=self.hostname, user='johndoe', password='my_very_secret')
            self.device.open()
            self.facts = self.device.facts
            try:
                if self.facts['switch_style'] == 'VLAN_L2NG':
                    self.is_els_type = True
            except KeyError:
                raise RuntimeError(f'Can not determine switch_style for {self.hostname}')
        except (exception.ConnectRefusedError, exception.ConnectTimeoutError, exception.ConnectUnknownHostError,
                exception.ProbeError, exception.ConnectError):
            raise ConnectionError

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.device.close()
        return True

    @property
    def get_facts(self):
        return self.facts


class JuniperSwitch(JuniperDevice):
    """Child Class to present data from a JuniperDevice"""
    type = 'Switch'

    @property
    def eth_ports_table(self):
        return EtherPortsTable(self.device).get()


def main():
    with JuniperSwitch(hostname='coolswitch') as dev:
        print(dev.facts)
        print(f'I am a {dev.vendor} and I am a {dev.type}')
        for port in dev.eth_ports_table:
            print(f'{port.name}')


if __name__ == '__main__':
    main()

Het zo abstract mogelijk maken is redelijk simpel. Als je uiteindelijk de herhaaldelijke patronen gaat zien en je dus de onderdelen (JuniperDevice vs JuniperSwitch) goed uitdenkt. Vaak kom je tot deze inzichten als je eenmaal verder gaat zoals in de alinea hierboven ook uitgeduid word.

Flask API via Apache

In de vorige post Mod_wsgi in Apache hebben we de module in Apache 2.4 op Centos7 beschikbaar gemaakt. Het gebruik daarentegen is de volgende stap.

Laten we eerst beginnen met een simpele API in de default document root ‘/var/www/html/’ waar we een directory maken api_test. Daarin maken we een virtuele omgeving via python3.8 en zetten wat structuur klaar:

mkdir /var/www/html/api_test
mkdir /var/www/html/api_test/api
cd /var/www/html/api_test
python3.8 -m venv venv
touch api.wsgi
touch api/__init__.py

We activeren de virtual environment en zorgen dat pip geüpdatet is in deze ‘venv’ en installeren we de juiste package, namelijk Flask:

source venv/bin/activate
python -m pip install --upgrade pip
pip install flask

De uiteindelijke directory structuur en bestanden word dan:

api_test/
├── api/
│   ├── __init__.py
├── venv/
└── api.wsgi

De API, voor nu een simpele JSON output van zichzelf en de Flask App zijn weggezet in het __init__.py bestand. Dit is een redelijk plat geval:

from flask import request, jsonify, Flask
import socket

app = Flask(__name__)
app.config['JSON_SORT_KEYS'] = False

@app.route('/', defaults={'path': ''})
@app.route('/', methods=['GET'])
def info():
    return jsonify({
        "name": "Test program",
        "version": "0.1a",
        "hostname": socket.gethostname(),
        "ip": request.remote_addr,
    }), 200

In het ‘wsgi’ bestand wat Apache gaat lezen instrueren we wat er gestart moet en een aantal path variabelen. De verwijzing naar de juiste package directory is vanwege de ‘virtual environment’. De andere is om de import van de API te laten werken. Uiteindelijk importeren wij dus uit de api de app als application

import sys
sys.path.insert(0, '/var/www/html/api_test/venv/lib/python3.8/site-packages')
sys.path.insert(1, '/var/www/html/api_test')

from api import app as application

Binnen Apache, ‘virtual hosts’ of niet, kan je een aantal parameters opnemen die de mod_wsgi aansturen en het bovengenoemde bestand uitvoeren. Dit moet ingevoerd worden binnen de VirtualHost tags of daar buiten:

    WSGIDaemonProcess api user=apache group=apache threads=5 home=/var/www/html/api_test
    WSGIScriptAlias /api/v1 /var/www/html/api_test/api.wsgi

    <Directory /var/www/html/api_test>
        WSGIProcessGroup api
        <IfModule mod_authz_core.c>
            Require all granted
        </IfModule>
    </Directory>

Na het herstarten van de ‘httpd’ daemon zou de api beschikbaar moeten zijn. Laten we het testen van een externe server:

[bartjanh@godlyserver ~]$ curl https://www.pc-mania.nl/api/v1/
{"name":"Test program","version":"0.1a","hostname":"mightyserver.pc-mania.nl","ip":"2001:9a0:2005:85::24"}

De API werkt! Er is nog veel meer maar de minimale basis zoals het er staat, werkt zoals het hoort.

Mod_wsgi in Apache

Om via een webserver Python scripts te kunnen gebruiken en specifiek Django of Flask voor bijvoorbeeld API deployments zal je de server iets bij moeten werken.

Onderstaand voorbeeld is hoe je dus Mod_wsgi activeert in een Centos7 server voorzien van Apache 2.4. De keuze op Python versie is sowieso 3.x en in dit voorbeeld 3.8.7.

Belangrijk is om root rechten te hebben. We beginnen met 2 environment variabelen te definiëren:

export PATH=$PATH:/usr/local/bin/
export LD_RUN_PATH=/usr/local/lib/

Hierna zorgen we dat de package apxs beschikbaar is, die via httpd-devel te vinden is:

yum install -y httpd-devel

Dan is het zaak Python te installeren. Vanwege redenen is Centos7 nog steeds standaard uitgerust met versie 2.7 en dat willen we verder niet gebruiken. Het is dus belangrijk dat de installatie naast de bestaande komt te draaien. Het hele Centos ecosysteem is afhankelijk van de huidige versie. Dat word met ‘make altinstall‘ bepaald.

Nog veel belangrijker is de ‘–enable-shared‘ optie waarme Python op de juiste manier compiled word. Vergeet deze niet!

cd /tmp
wget https://www.python.org/ftp/python/3.8.7/Python-3.8.7.tgz
tar -xf Python-3.8.7.tgz -C /usr/local/src
cd /usr/local/src/Python-3.8.7
./configure --enable-shared --enable-optimizations
make altinstall

Als het goed is kan je daarna Python3.8 starten:

[root@mightyserver ~]# python3.8
Python 3.8.7 (default, Jan  2 2021, 14:19:51)
[GCC 4.8.5 20150623 (Red Hat 4.8.5-44)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

Voor we de module mod_wsgi introduceren in Apache moet er nog een kleine update gedaan worden:

/usr/local/bin/python3.8 -m pip install --upgrade pip

Nu kunnen we via ‘Pip’ de installatie van de module uitvoeren en deze configureren:

pip3.8 install mod_wsgi
mod_wsgi-express install-module > /etc/httpd/conf.modules.d/02-wsgi.conf

systemctl restart httpd

Een controle kan gedaan worden of de module beschikbaar is:

[root@mightyserver ~]# httpd -t -D DUMP_MODULES | grep wsgi
 wsgi_module (shared)

In de volgende post meer over hoe nu Apache te ‘sturen’ is naar jouw Python project.