Generazione automatica e caricamento di configurazioni di dispositivi di rete utilizzando Nornir.

Generazione automatica e caricamento di configurazioni di dispositivi di rete utilizzando Nornir.

Ciao, Habr!

Recentemente è apparso un articolo Mikrotik e Linux. Routine e automazione dove un compito simile è stato risolto con mezzi obsoleti. E anche se il compito è abbastanza tipico, non sembra esserci nulla di simile su Habr. Mi permetto di proporre alla rispettabile comunità IT la mia invenzione.

Questa non è la prima invenzione per un compito simile. La prima versione è stata realizzata alcuni anni fa, ancora su ansible versione 1.x.x. L'invenzione è stata utilizzata raramente e quindi arrugginiva constantemente. Nel senso che il compito stesso non si presenta così spesso come vengono aggiornate le versioni ansible. E ogni volta che era necessario intervenire, o la catena si allentava, o la ruota cadeva. Tuttavia, la prima parte, la generazione di configurazioni, funziona sempre molto bene, grazie al fatto che jinja2 il motore è da tempo consolidato. Ma la seconda parte — l'applicazione delle configurazioni, di solito portava delle sorprese. E dato che devo applicare le configurazioni da remoto su una cinquantina di dispositivi, alcuni dei quali si trovano a migliaia di chilometri, usare questo strumento era leggermente inquietante.

Qui devo riconoscere che la mia mancanza di fiducia deriva più dalla mia scarsa familiarità con ansible, piuttosto che dai suoi difetti. E questo, tra l'altro, è 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 abbastanza rapidamente, senza particolare considerazione per la retrocompatibilità, non aggiunge fiducia.

Pertanto, non molto tempo fa, è stata realizzata una seconda versione dell'invenzione. Questa volta su python, o meglio su un framework scritto in python e per python chiamata Nornir

Quindi — Nornir è un micro-framework scritto in python e per python e destinato all'automazione. Come nel caso di ansible, per affrontare le sfide qui è necessaria una preparazione adeguata dei dati, cioè un'inventario degli host e delle loro caratteristiche, mentre gli script non vengono scritti in un DSL separato, ma tutto è fatto in quel python non molto vecchio, ma molto buono.

Esaminiamo ciò di cui si tratta con un esempio concreto.

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

Ora il compito: 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 il limite di velocità per la porta dedicata.

Prima di tutto, dobbiamo preparare un paio di modelli, su cui verranno generate le configurazioni separatamente per Cisco e Juniper. È inoltre necessario raccogliere i dati per ciascun punto e le caratteristiche 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 "Video surveillance"
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, naturalmente, non vengono dal nulla. Sono essenzialmente differenze tra le configurazioni operative, prima e dopo la soluzione 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 specificare questi parametri per ciascun dispositivo, cioè fare quell'inventario. inventory.

Per inventory seguiremo rigorosamente la documentazione Inizializzando Nornir

cioè creeremo 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 verranno specificati nel file hosts.yaml, gruppi (nel mio caso si tratta di login/password) in groups.yaml, e nella defaults.yaml non indicheremo nulla, ma qui è necessario scrivere tre segni meno — che indicano che questo è yaml 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 è presentato inventory per il nostro compito. Durante l'inizializzazione, i parametri dei file di inventario vengono mappati sul modello dell'oggetto InventoryElement.

Sotto il spoiler c'è 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' confuso, specialmente all'inizio. Per comprendere meglio, è molto utile la modalità interattiva in ipython.

 $ ipython3
Python 3.6.9 (default, Nov  7 2019, 10:44:02) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.1.1 -- An enhanced Interactive Python. Type '?' for help.

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 proprio allo script. Non c'è molto di cui essere orgogliosi qui. Ho semplicemente preso un esempio già pronto dal tutorial e l'ho utilizzato quasi senza modifiche. Ecco come appare lo script finale 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="Base Configuration",
                 template="base.j2",
                 path=f"templates/{task.host.platform}")

    # Salva la configurazione compilata in una variabile dell'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)

    # Distribuisce quella configurazione al dispositivo utilizzando NAPALM
    task.run(task=networking.napalm_configure,
             name="Loading Configuration on the device",
             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)

Si prega di prestare attenzione al parametro dry_run=True nella riga di inizializzazione dell'oggetto nr.
Qui, come in ansible è implementata una prova di test in cui avviene la connessione al router, viene preparata una nuova configurazione modificata che viene poi validata dal dispositivo (ma non è sicuro; dipende dal supporto del dispositivo e dall'implementazione del driver in NAPALM), ma non viene applicata direttamente la nuova configurazione. Per un'applicazione reale è necessario rimuovere il parametro dry_run o cambiarne il valore in Falso.

Quando si esegue lo script, Nornir fornisce dettagliati log sulla console.

Sotto il spoiler, l'output di un'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 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 "Videosorveglianza"
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 Configurazione sul dispositivo ** cambiato : True --------------------- INFO
[edit interfaces]
+   fe-0/0/2 {
+       unit 0 {
+           description "Videosorveglianza";
+           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 citato. ansibleMa non è tutto così male. Mi piace molto il loro. vault che è progettato per nascondere informazioni sensibili. E probabilmente molti di voi hanno notato che abbiamo tutte le credenziali dei nostri router di produzione esposte in chiaro nel file. gorups.yaml. Questo non è affatto elegante. Proteggiamo quindi questi dati utilizzando. vault.

Trasferiamo i parametri da groups.yaml a creds.yaml e cripto con AES256 utilizzando 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 avvenuta con successo
$ cat creds.yaml 
$ANSIBLE_VAULT;1.1;AES256
39656463353437333337356361633737383464383231366233386636333965306662323534626131
3964396534396333363939373539393662623164373539620a346565373439646436356438653965
39643266333639356564663961303535353364383163633232366138643132313530346661316533
6236306435613132610a656163653065633866626639613537326233653765353661613337393839
62376662303061353963383330323164633162386336643832376263343634356230613562643533
30363436343465306638653932366166306562393061323636636163373164613630643965636361
34343936323066393763323633336366366566393236613737326530346234393735306261363239
35663430623934323632616161636330353134393435396632663530373932383532316161353963
31393434653165613432326636616636383665316465623036376631313162646435

Ecco fatto. Rimane solo da insegnare al nostro Nornir-script di prelevare e applicare questi dati.
Per 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, incrociate le dita e riprova

# arricchisci l'Inventario con i dati del vault crittografato
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 i compiti
...

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

Per ora è tutto. Sono in arrivo altri articoli su Cisco + Zabbix, ma questo è un po' diverso dall'automazione. Nel prossimo futuro, ho intenzione di scrivere su RESTCONF in Cisco.

Fonte: habr.com

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