Autogenerazione e distribuzione degli elementi di configurazione dei dispositivi di rete tramite Nornir

Autogenerazione e distribuzione degli elementi di configurazione dei dispositivi di rete tramite Nornir

Ciao, Habr!

Recentemente è apparso un articolo Mikrotik e Linux. Routine e automazione dove compiti simili sono stati risolti con mezzi arcaici. E anche se il compito è del tutto tipico, su Habr non si trova niente di simile. Mi permetto di proporre alla rispettata comunità IT la mia bicicletta.

Questa non è la prima bicicletta per un compito simile. La prima versione è stata realizzata qualche anno fa ancora su ansible versione 1.x.x. La bicicletta è stata usata raramente e pertanto arrugginiva costantemente. Nel senso che il compito stesso non si presenta così spesso come vengono aggiornate le versioni ansible. E ogni volta che è necessario farlo, la catena scivola o la ruota si stacca. Comunque, la prima parte, generazione di configurazioni, funziona sempre molto bene, per fortuna jinja2 il motore è da tempo consolidato. Ma la seconda parte, il rollout delle configurazioni, di solito presenta sorprese. E dato che devo effettuare il rollout delle configurazioni da remoto su una cinquantina di dispositivi, alcuni dei quali si trovano a migliaia di chilometri, usare questo strumento era un po' preoccupante.

Qui bisogna riconoscere che la mia insicurezza deriva probabilmente dalla mia scarsa familiarità con ansible, più che dai suoi difetti. E questo, per inciso, è un punto importante. ansible — è un'area di conoscenza completamente separata, con un proprio DSL (Domain Specific Language), che deve essere mantenuto a un livello di competenza sufficiente. E il fatto che ansible si sviluppa piuttosto rapidamente, senza particolare riguardo alla retrocompatibilità, non aiuta a sentirsi più sicuri.

Pertanto, non molto tempo fa è stata realizzata una seconda versione della bicicletta. Questa volta su python, o meglio su un framework scritto in python e per python denominato Nornir

Quindi — Nornir è un microframework scritto in python e per python e destinato all'automazione. Così come nel caso con ansible, per risolvere i compiti qui è necessaria una preparazione accurata dei dati, cioè l'inventario degli host e dei loro parametri, mentre gli script non sono scritti in un DSL separato, ma tutto nello stesso Python, che non è molto vecchio, ma decisamente buono.

Esaminiamo cosa sia esattamente attraverso il seguente esempio pratico.

Ho una rete di filiali con diverse decine di uffici in tutto il paese. In ogni ufficio c'è un router WAN che termina diversi canali di comunicazione da vari operatori. Il protocollo di routing è BGP. I router WAN sono di due tipi: Cisco ISG o Juniper SRX.

Ora il compito: è necessario configurare in tutti i router WAN della rete filiale una sottorete dedicata per la Videosorveglianza su una porta separata — annunciare questa sottorete in BGP — configurare un limite di velocità per la porta dedicata.

Prima dobbiamo preparare un paio di modelli, sui quali verranno generate le configurazioni separatamente per Cisco e Juniper. È inoltre necessario raccogliere i dati relativi a ogni punto e le relative opzioni di connessione, cioè raccogliere quell'inventario.

Modello pronto per Cisco:

$ cat templates/ios/base.j2 
class-map match-all VIDEO_SURV
 match access-group 111

policy-map VIDEO_SURV
 class VIDEO_SURV
    police 1500000 conform-action transmit  exceed-action drop

interface {{ host.task_data.ifname }}
  description VIDEOSURV
  ip address 10.10.{{ host.task_data.ipsuffix }}.254 255.255.255.0
  service-policy input VIDEO_SURV

router bgp {{ host.task_data.asn }}
  network 10.40.{{ host.task_data.ipsuffix }}.0 mask 255.255.255.0

access-list 11 permit 10.10.{{ host.task_data.ipsuffix }}.0 0.0.0.255
access-list 111 permit ip 10.10.{{ host.task_data.ipsuffix }}.0 0.0.0.255 any

Modello per Juniper:

$ cat templates/junos/base.j2 
set interfaces {{ host.task_data.ifname }} unit 0 description "Videosorveglianza"
set interfaces {{ host.task_data.ifname }} unit 0 family inet filter input limit-in
set interfaces {{ host.task_data.ifname }} unit 0 family inet address 10.10.{{ host.task_data.ipsuffix }}.254/24
set policy-options policy-statement export2bgp term 1 from route-filter 10.10.{{ host.task_data.ipsuffix }}.0/24 exact
set security zones security-zone WAN interfaces {{ host.task_data.ifname }}
set firewall policer policer-1m if-exceeding bandwidth-limit 1m
set firewall policer policer-1m if-exceeding burst-size-limit 187k
set firewall policer policer-1m then discard
set firewall policer policer-1.5m if-exceeding bandwidth-limit 1500000
set firewall policer policer-1.5m if-exceeding burst-size-limit 280k
set firewall policer policer-1.5m then discard
set firewall filter limit-in term 1 then policer policer-1.5m
set firewall filter limit-in term 1 then count limiter

I modelli, ovviamente, non vengono dal nulla. Sono essenzialmente le differenze tra le configurazioni operative prima e dopo la risoluzione del compito su due router specifici di modelli diversi.

Dai nostri modelli vediamo che per risolvere il compito abbiamo bisogno di due parametri per Juniper e 3 parametri per Cisco. Eccoli:

  • ifname
  • ipsuffix
  • asn

Ora dobbiamo impostare questi parametri per ogni dispositivo, cioè fare quell'inventario. inventory.

Per inventory seguiremo rigorosamente la documentazione Inizializzando Nornir

cioè creiamo una struttura di file simile:

.
├── config.yaml
├── inventory
│   ├── defaults.yaml
│   ├── groups.yaml
│   └── hosts.yaml

Il file config.yaml è il file di configurazione standard di nornir

$ cat config.yaml 
---
core:
    num_workers: 10

inventory:
    plugin: nornir.plugins.inventory.simple.SimpleInventory
    options:
        host_file: "inventory/hosts.yaml"
        group_file: "inventory/groups.yaml"
        defaults_file: "inventory/defaults.yaml"

I parametri principali saranno specificati nel file hosts.yaml, i gruppi (nel mio caso si tratta di username/password) in groups.yaml, e in defaults.yaml non specificeremo nulla, ma è necessario inserire tre trattini — che indicano che si tratta di yaml anche se il file è vuoto.

Ecco come appare hosts.yaml:

---
srx-test:
    hostname: srx-test
    groups: 
        - juniper
    data:
        task_data:
            ifname: fe-0/0/2
            ipsuffix: 111

cisco-test:
    hostname: cisco-test
    groups: 
        - cisco
    data:
        task_data:
            ifname: GigabitEthernet0/1/1
            ipsuffix: 222
            asn: 65111

Ecco come appare groups.yaml:

---
cisco:
    platform: ios
    username: admin1
    password: cisco1

juniper:
    platform: junos
    username: admin2
    password: juniper2

Ecco come si presenta inventory per il nostro compito. Durante l'inizializzazione, i parametri dai file di inventory vengono mappati al modello oggetto InventoryElement.

Sotto il spoiler, lo schema del modello InventoryElement

print(json.dumps(InventoryElement.schema(), indent=4))
{
    "title": "InventoryElement",
    "type": "object",
    "properties": {
        "hostname": {
            "title": "Hostname",
            "type": "string"
        },
        "port": {
            "title": "Port",
            "type": "integer"
        },
        "username": {
            "title": "Username",
            "type": "string"
        },
        "password": {
            "title": "Password",
            "type": "string"
        },
        "platform": {
            "title": "Platform",
            "type": "string"
        },
        "groups": {
            "title": "Groups",
            "default": [],
            "type": "array",
            "items": {
                "type": "string"
            }
        },
        "data": {
            "title": "Data",
            "default": {},
            "type": "object"
        },
        "connection_options": {
            "title": "Connection_Options",
            "default": {},
            "type": "object",
            "additionalProperties": {
                "$ref": "#/definitions/ConnectionOptions"
            }
        }
    },
    "definitions": {
        "ConnectionOptions": {
            "title": "ConnectionOptions",
            "type": "object",
            "properties": {
                "hostname": {
                    "title": "Hostname",
                    "type": "string"
                },
                "port": {
                    "title": "Port",
                    "type": "integer"
                },
                "username": {
                    "title": "Username",
                    "type": "string"
                },
                "password": {
                    "title": "Password",
                    "type": "string"
                },
                "platform": {
                    "title": "Platform",
                    "type": "string"
                },
                "extras": {
                    "title": "Extras",
                    "type": "object"
                }
            }
        }
    }
}

Questo modello può sembrare un po' complicato, soprattutto all'inizio. Per orientarsi, la modalità interattiva in ipython.

 $ ipython3
Python 3.6.9 (default, 7 nov 2019, 10:44:02) 
Digita 'copyright', 'credits' o 'license' per ulteriori informazioni
IPython 7.1.1 -- Un Python Interattivo migliorato. Digita '?' per aiuto.

In [1]: from nornir import InitNornir                                                                           

In [2]: nr = InitNornir(config_file="config.yaml", dry_run=True)                                                

In [3]: nr.inventory.hosts                                                                                      
Out[3]: 
{'srx-test': Host: srx-test, 'cisco-test': Host: cisco-test}

In [4]: nr.inventory.hosts['srx-test'].data                                                                                    
Out[4]: {'task_data': {'ifname': 'fe-0/0/2', 'ipsuffix': 111}}

In [5]: nr.inventory.hosts['srx-test']['task_data']                                                     
Out[5]: {'ifname': 'fe-0/0/2', 'ipsuffix': 111}

In [6]: nr.inventory.hosts['srx-test'].platform                                                                                
Out[6]: 'junos'

E infine passiamo allo script vero e proprio. Qui non ho nulla di cui vantarmi. Ho semplicemente preso un esempio pronto da un tutorial e l'ho utilizzato quasi senza modifiche. Ecco come appare lo script funzionante:

from nornir import InitNornir
from nornir.plugins.tasks import networking, text
from nornir.plugins.functions.text import print_title, print_result

def config_and_deploy(task):
    # Trasforma i dati dell'inventario in configurazione tramite un file template
    r = task.run(task=text.template_file,
                 name="Configurazione di Base",
                 template="base.j2",
                 path=f"templates/{task.host.platform}")

    # Salva la configurazione compilata in una variabile host
    task.host["config"] = r.result

    # Salva la configurazione compilata in un file
    with open(f"configs/{task.host.hostname}", "w") as f:
        f.write(r.result)

    # Applica quella configurazione al dispositivo usando NAPALM
    task.run(task=networking.napalm_configure,
             name="Caricamento della Configurazione sul dispositivo",
             replace=False,
             configuration=task.host["config"])

nr = InitNornir(config_file="config.yaml", dry_run=True) # imposta dry_run=False, incrocia le dita e riesegui

# esegui i task
result = nr.run(task=config_and_deploy)
print_result(result)

Fai attenzione al parametro dry_run=True nella riga di inizializzazione dell'oggetto nr.
Qui c'è anche un test eseguito in cui avviene la connessione con il router, si prepara una nuova configurazione modificata, che viene poi validata dal dispositivo (ma non è certo; dipende dal supporto del dispositivo e dall'implementazione del driver in NAPALM), ma non avviene l'applicazione diretta della nuova configurazione. Per l'uso operativo è necessario rimuovere il parametro ansible dry_run oppure cambiare il suo valore in False False.

Sotto spoiler, l'output di un esecuzione operativa su due router di test:

Sotto spoiler l'output dell'esecuzione reale su due router di test:

config_and_deploy***************************************************************
* cisco-test ** cambiato : True *******************************************
vvvv config_and_deploy ** cambiato : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- Configurazione di Base ** cambiato : True ------------------------------------- INFO
class-map match-all VIDEO_SURV
 match access-group 111

policy-map VIDEO_SURV
 class VIDEO_SURV
    police 1500000 conform-action transmit  exceed-action drop

interface GigabitEthernet0/1/1
  description VIDEOSURV
  ip address 10.10.222.254 255.255.255.0
  service-policy input VIDEO_SURV

router bgp 65001
  network 10.10.222.0 mask 255.255.255.0

access-list 11 permit 10.10.222.0 0.0.0.255
access-list 111 permit ip 10.10.222.0 0.0.0.255 any
---- Caricamento della Configurazione sul dispositivo ** cambiato : True --------------------- INFO
+class-map match-all VIDEO_SURV
+ match access-group 111
+policy-map VIDEO_SURV
+ class VIDEO_SURV
+interface GigabitEthernet0/1/1
+  description VIDEOSURV
+  ip address 10.10.222.254 255.255.255.0
+  service-policy input VIDEO_SURV
+router bgp 65001
+  network 10.10.222.0 mask 255.255.255.0
+access-list 11 permit 10.10.222.0 0.0.0.255
+access-list 111 permit ip 10.10.222.0 0.0.0.255 any
^^^^ FINE config_and_deploy ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* srx-test ** cambiato : True *******************************************
vvvv config_and_deploy ** cambiato : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- Configurazione di Base ** cambiato : True ------------------------------------- INFO
set interfaces fe-0/0/2 unit 0 description "Video sorveglianza"
set interfaces fe-0/0/2 unit 0 family inet filter input limit-in
set interfaces fe-0/0/2 unit 0 family inet address 10.10.111.254/24
set policy-options policy-statement export2bgp term 1 from route-filter 10.10.111.0/24 exact
set security zones security-zone WAN interfaces fe-0/0/2
set firewall policer policer-1m if-exceeding bandwidth-limit 1m
set firewall policer policer-1m if-exceeding burst-size-limit 187k
set firewall policer policer-1m then discard
set firewall policer policer-1.5m if-exceeding bandwidth-limit 1500000
set firewall policer policer-1.5m if-exceeding burst-size-limit 280k
set firewall policer policer-1.5m then discard
set firewall filter limit-in term 1 then policer policer-1.5m
set firewall filter limit-in term 1 then count limiter
---- Caricamento della Configurazione sul dispositivo ** cambiato : True --------------------- INFO
[edit interfaces]
+   fe-0/0/2 {
+       unit 0 {
+           description "Video sorveglianza";
+           family inet {
+               filter {
+                   input limit-in;
+               }
+               address 10.10.111.254/24;
+           }
+       }
+   }
[edit]
+  policy-options {
+      policy-statement export2bgp {
+          term 1 {
+              from {
+                  route-filter 10.10.111.0/24 exact;
+              }
+          }
+      }
+  }
[edit security zones]
     security-zone test-vpn { ... }
+    security-zone WAN {
+        interfaces {
+            fe-0/0/2.0;
+        }
+    }
[edit]
+  firewall {
+      policer policer-1m {
+          if-exceeding {
+              bandwidth-limit 1m;
+              burst-size-limit 187k;
+          }
+          then discard;
+      }
+      policer policer-1.5m {
+          if-exceeding {
+              bandwidth-limit 1500000;
+              burst-size-limit 280k;
+          }
+          then discard;
+      }
+      filter limit-in {
+          term 1 {
+              then {
+                  policer policer-1.5m;
+                  count limiter;
+              }
+          }
+      }
+  }
^^^^ FINE config_and_deploy ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Nascondiamo le password in ansible_vault

All'inizio dell'articolo ho un po' criticato ansible, ma in realtà non è tutto così male. Mi piacciono molto da loro vault mi piace, che è progettato per nascondere informazioni sensibili lontano dagli occhi. E probabilmente molti hanno notato che tutte le nostre credenziali/login per tutti i router di rete appaiono in chiaro nel file gorups.yaml. È decisamente brutto. Proteggiamo questi dati usando vault.

Spostiamo i parametri da groups.yaml a creds.yaml e lo criptiamo con AES256 con una password di 20 caratteri:

$ cd inventory
$ cat creds.yaml
---
cisco:
    username: admin1
    password: cisco1

juniper:
    username: admin2
    password: juniper2

$ pwgen 20 -N 1 > vault.passwd
ansible-vault encrypt creds.yaml --vault-password-file vault.passwd  
Crittografia riuscita
$ cat creds.yaml 
$ANSIBLE_VAULT;1.1;AES256
39656463353437333337356361633737383464383231366233386636333965306662323534626131
3964396534396333363939373539393662623164373539620a346565373439646436356438653965
39643266333639356564663961303535353364383163633232366138643132313530346661316533
6236306435613132610a656163653065633866626639613537326233653765353661613337393839
62376662303061353963383330323164633162386336643832376263343634356230613562643533
30363436343465306638653932366166306562393061323636636163373164613630643965636361
34343936323066393763323633336366366566393236613737326530346234393735306261363239
35663430623934323632616161636330353134393435396632663530373932383532316161353963
31393434653165613432326636616636383665316465623036376631313162646435

È così semplice. Ora rimane da insegnare al nostro Nornir-scritto a recuperare e applicare questi dati.
Per fare questo, nel nostro script dopo la riga di inizializzazione nr = InitNornir(config_file=… aggiungiamo il seguente codice:

...
nr = InitNornir(config_file="config.yaml", dry_run=True) # imposta dry_run=False, incrocia le dita e ripeti

# arricchisci l'inventario con i dati criptati dal vault
from ansible_vault import Vault
vault_password_file="inventory/vault.passwd"
vault_file="inventory/creds.yaml"
with open(vault_password_file, "r") as fp:
    password = fp.readline().strip()   
    vault = Vault(password)
    vaultdata = vault.load(open(vault_file).read())

for a in nr.inventory.hosts.keys():
    item = nr.inventory.hosts[a]
    item.username = vaultdata[item.groups[0]]['username']
    item.password = vaultdata[item.groups[0]]['password']
    #print("hostname={}, username={}, password={}n".format(item.hostname, item.username, item.password))

# esegui compiti
...

Naturalmente vault.passwd non dovrebbe trovarsi accanto a creds.yaml come nel mio esempio. Ma per divertirsi va bene.

Per ora è tutto. Sono in arrivo altri articoli su Cisco + Zabbix, ma questo è un po' fuori tema rispetto all'automazione. E nel prossimo futuro ho in programma di scrivere su RESTCONF in Cisco.

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster