Implementing ACLs using automation libraries

Hello

Over the next few weeks I’d like to test a few automation libraries to see which ones are easier to use than others. I hope this can be a useful introduction for those without much prior experience with automation or coding in general.
I don’t have much experience with python as I only did some programming in java a long time ago so this entry has a lot of code borrowed from all over the internet 🙂 We are all learning here so if you see something particularly noobish, feel free to laugh out loud.

I first launched GNS3 on ubuntu 20.04 and prepared a simple topology

GNS3_netmiko

I connected ubuntu to gns3 using the nat cloud. R1 is 192.168.122.100.

NETMIKO

My goal was to implement a simple ACL on R1. To do that, i’ve prepared an acl.j2 template file:

{% for accesslist in data.accesslists %}
ip access-list extended {{accesslist.aclname}}
{{accesslist.aclcommand}} {{accesslist.protocol}} {{accesslist.source}} {{accesslist.sourcewildcard}} {{accesslist.destination}} {{accesslist.destwildcard}}
{% endfor %}

Then I prepared the acl data in the acl.yml file:

accesslists:
- aclname: permit_www
aclcommand: permit
protocol: ip
source: 192.168.122.100
sourcewildcard: 0.0.0.0
destination: 8.8.8.8
destwildcard: 0.0.0.0

Finally, the python script.

import sys
from netmiko import ConnectHandler
import time
from netmiko import redispatch
from netmiko import Netmiko
from jinja2 import Environment, FileSystemLoader
import yaml

def connect(hostip):

net_connect = ConnectHandler(device_type='cisco_ios', host=hostip, username='cisco', password='cisco', secret='cisco')
net_connect.find_prompt()
net_connect.enable()
accesslists = yaml.load(open('acl.yml'), Loader=yaml.SafeLoader)
env = Environment(loader = FileSystemLoader)
template = env.get_template('acl.j2') 
acl_config = template.render(data=accesslists) 
print(acl_config) 
print(f"Logged into {hostip} successfully") 
output = net_connect.send_config_set(acl_config.split("\n")) 
#split method returns a list, that can be used in the send_config_set
if __name__ == "__main__": 
ip = sys.argv[1] 
connect(ip)


I then ran the script:

tode@ubuntu:~/nmikoymlproj$ python3 firstscript.py 192.168.122.100
ip access-list extended permit_www
permit ip 192.168.122.100 0.0.0.0 8.8.8.8 0.0.0.0

Logged into 192.168.122.100 successfully


 

UNICON

from unicon import Connection
import time
from jinja2 import Environment, FileSystemLoader
import yaml

#this prepares the config
accesslists = yaml.load(open('acl2.yml'), Loader=yaml.SafeLoader)
env = Environment(loader = FileSystemLoader('.'), trim_blocks=True, autoescape=True)
template = env.get_template('acl.j2')
acl2_config = template.render(data=accesslists)
preoutput = acl2_config.split("\n")

#this prepares the connection
c = Connection(hostname='R1', start=['telnet ' + router1], os='ios', credentials={'default': {'username': 'cisco', 'password': 'cisco'}, 'secret': 'cisco'},)
c.connect()

#This checks how many commands the config set has. This is because unicon's configure method adds ''end'' after each command. So in case of nested commands we need to push the whole set of commands (e.g. interface loop0,ip addr 1.1.1.1 255.255.255.255). So command is a list, to which I push all elements of the config, then the whole list is pushed to the router.

x = len(preoutput)
y=0
command = []
while y<x:
  command.append(preoutput[x-x+y])
  y = y+1

#this actually configures the router
print(command)output = c.configure(command)

and the output…

tode@ubuntu:~/nmikoymlproj$ python3 uniconscript.py

2020-08-03 04:12:14,672: %UNICON-INFO: +++ R1 logfile /tmp/R1-20200803T041214670.log +++

2020-08-03 04:12:14,672: %UNICON-INFO: +++ Unicon plugin ios +++
ip access-list extended permit_www
permit ip 192.168.122.220 0.0.0.0 9.9.9.9 0.0.0.0

Logged into 192.168.122.100 successfully
Trying 192.168.122.100...


2020-08-03 04:12:14,706: %UNICON-INFO: +++ connection to spawn: telnet 192.168.122.100, id: 140510945062576 +++

2020-08-03 04:12:14,707: %UNICON-INFO: connection to R1
Connected to 192.168.122.100.
Escape character is '^]'.


User Access Verification

Username: cisco
Password: 
R1#

2020-08-03 04:12:15,391: %UNICON-INFO: +++ initializing handle +++

2020-08-03 04:12:15,391: %UNICON-INFO: +++ R1: executing command 'term length 0' +++
term length 0
R1#

2020-08-03 04:12:15,510: %UNICON-INFO: +++ R1: executing command 'term width 0' +++
term width 0
R1#

2020-08-03 04:12:15,670: %UNICON-INFO: +++ R1: executing command 'show version' +++
show version
Cisco IOS Software, 7200 Software (C7200-ADVENTERPRISEK9-M), Version 12.4(24)T5, RELEASE SOFTWARE (fc3)
Technical Support: http://www.cisco.com/techsupport

(...)

Configuration register is 0x2102

R1#

2020-08-03 04:12:15,820: %UNICON-INFO: +++ R1: config +++
config term
Enter configuration commands, one per line.  End with CNTL/Z.
R1(config)#no logging console
R1(config)#line console 0
R1(config-line)#exec-timeout 0
R1(config-line)#end
R1#
['ip access-list extended permit_www', 'permit ip 192.168.122.220 0.0.0.0 9.9.9.9 0.0.0.0', '']

2020-08-03 04:12:16,007: %UNICON-INFO: +++ R1: config +++
config term
Enter configuration commands, one per line.  End with CNTL/Z.
R1(config)#ip access-list extended permit_www
R1(config-ext-nacl)#permit ip 192.168.122.220 0.0.0.0 9.9.9.9 0.0.0.0
R1(config-ext-nacl)#end
R1#

 

Now let’s take the hosts into an inventory yml file:

from unicon import Connection
from jinja2 import Environment, FileSystemLoader
import yaml
from pyats.topology import loader

#this prepares the config and command sets
accesslists = yaml.load(open('acl2.yml'), Loader=yaml.SafeLoader)
env = Environment(loader = FileSystemLoader('.'), trim_blocks=True, autoescape=True)
template = env.get_template('acl.j2')
acl2_config = template.render(data=accesslists)
preoutput = acl2_config.split("\n")
x = len(preoutput)
y=0
command = []
while y<x:
  command.append(preoutput[x-x+y])
  y = y+1

#this loads the testbed topology and connects
testbed = loader.load("testbed.yml")
c = testbed.devices['R1']
c.connect()

#actual configuration
output = c.configure(command)

where the inventory file looks as follows:

devices:
    R1:
       connections:
          cli:
             ip: 192.168.122.100
             port: 23 
             protocol: telnet
      credentials:
         default:
             password: cisco
             username: cisco
         enable:
              password: cisco
     os: ios
     type: ios

 

Now this code is still not ideal because too many things are hardcoded:

  • acl.j2 template
  • acl.yml file with the config
  • name of router from the testbed

So let’s do move them out into sys.args + a bit of refactoring to have everything neat

def prepareconfig(template,configdata):
  accesslists = yaml.load(open(configdata), Loader=yaml.SafeLoader)
  env = Environment(loader = FileSystemLoader('.'), trim_blocks=True, autoescape=True)
  template = env.get_template(template)
  realconfig = template.render(data=accesslists)
  preoutput = realconfig.split("\n")
  x = len(preoutput)
  y=0
  command = []
  while y<x:
     command.append(preoutput[x-x+y])
     y = y+1
  return command

def connect(testbedfile,devicename):
  testbed = loader.load(testbedfile)
  c = testbed.devices[devicename]
  c.connect()
  return c

def configure(command,connection):
  thiscommand = command
  c = connection
  output = c.configure(thiscommand)

##USAGE   python3 script.py acl.j2 acl.yml R1 testbed.yml

if __name__ == "__main__":
  template = sys.argv[1]
  configdata = sys.argv[2]
  devicename = sys.argv[3]
  testbedfile = sys.argv[4]
  configure(prepareconfig(template,configdata),connect(testbedfile,devicename))



This seems quite easy, but for every feature it is necessary to come up with a .j2 template and prepare data according to the template.

ANSIBLE

- name: add_entry_to_acl 
hosts: testrouter
tasks:
- name: add_new_entry
ios_config: 
lines:
- permit ip 10.10.10.10 0.0.0.0 11.11.11.11 0.0.0.0
parents: ip access-list extended permit_www
before: ip access-list extended permit_www
match: exact
authorize: yes

well ain’t that faster than typing up all that python code myself…

Obviously there are things hardcoded in this so let’s do some more work here.

I’ve added an acl20 var to my testrouter.yml under host_vars and changed the playbook in the following way:

- name: modify_acl_entries
  hosts: testrouter
  tasks:
  - name: add_new_entry
    ios_config: 
    lines:
     - "{{ acl20 }}" 
    parents: ip access-list extended permit_www
    before: ip access-list extended permit_www
    save_when: modified

and the output is…

tode@ubuntu:~/ansiblefolder$ ansible-playbook aclplaybook.yml

PLAY [modify_acl_entries] ********************************************************

TASK [add_new_entry] ***********************************************************
[DEPRECATION WARNING]: Distribution Ubuntu 20.04 on host testrouter should use 
/usr/bin/python3, but is using /usr/bin/python for backward compatibility with 
prior Ansible releases. A future Ansible release will default to using the 
discovered platform python for this host. See https://docs.ansible.com/ansible/
2.9/reference_appendices/interpreter_discovery.html for more information. This 
feature will be removed in version 2.12. Deprecation warnings can be disabled 
by setting deprecation_warnings=False in ansible.cfg.
changed: [testrouter]

PLAY RECAP *********************************************************************
testrouter : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0


 

Napalm

I’ve had no previous experience with this one so let’s start from scratch: First, the installation:

pip3 install napalm

Now, on the router in GNS3 you need to have a working file system, on my 7200 router I had to increase the size of disk0 under router template, then issue: format disk0: and you’re good to go.

I created an acltest.cfg file with a simple acl:

ip access-list extended napalm_acl

 permit host 1.2.3.4 host 7.8.9.10

Then I created a simple python napalm script:

from napalm.base import get_network_driver

driver = get_network_driver('ios')
dev = driver('192.168.122.100', 'cisco', 'cisco',)

dev.open()
dev.load_merge_candidate(filename='acltest.cfg')
dev.commit_config()
dev.close()

ok, quite easy, but there are so many things hardcoded here. Let’s create an inventory file first:

{
   "r1": {
            "IP": "192.168.122.100",
            "type": "ios",
            "user": "cisco",
            "password": "cisco"
         },
    "r2": {
            "IP": "192.168.122.101",
            "type": "ios",
            "user": "cisco",
            "password": "cisco"
          }
}

and now let’s rework the script a bit

from napalm.base import get_network_driver
import sys
import json

hostname = sys.argv[1]
acl_file = sys.argv[2]


with open("inventory.json", "r") as f:
    dev_db = json.load(f)
dev_param = dev_db[hostname.lower()]
driver = get_network_driver(dev_param['type'])
with driver(dev_param['IP'], dev_param['user'], dev_param['password']) as device:

device.open()
device.load_merge_candidate(acl_file)
device.commit_config()
device.close()

and now let’s run the script

tode@ubuntu:~/napalmproj$ python3 napalm_script.py R1 acltest.cfg

Alternatively, I could use here j2 templates, process the template and data, output a config file and use it instead of having a ”ready config” (useful if the operator is not familiar with ios). Obviously now it is possible to mix and match all the approaches that i’ve gone thru so far.

 

Ansible+Napalm

 

Update@17.08.2020
This one was surprisingly difficult to set up but i really liked the result. I created a config file that i wanted to merge with the running config on the target device.

ip access-list ext ansnap
permit ip 16.17.18.19 0.0.0.0 20.21.22.23 0.0.0.0

I also prepared my inventory.yml file

[gnsrouter]
testrouter

In group_vars, i created gnsrouter.yml with the following settings:

ansible_connection : network_cli
ansible_network_os : ios
ansible_python_interpreter : "/usr/bin/python3"

 

I then created the playbook:

- name: merge_config
hosts: testrouter
gather_facts: no 
tasks:
- name: whatever
  napalm_install_config:
   hostname: '192.168.122.100'
   username: 'cisco'
   password: 'cisco'
   dev_os: "ios"
   optional_args:
    port: 22
   config_file: 'config_file' 
   commit_changes: True
   replace_config: False
   get_diffs: True
   diff_file: 'R1diff.txt'

 

Now i run the playbook:

tode@ubuntu:~/ansiblefolder$ ansible-playbook ansnap.yml

PLAY [merge_config] ************************************************************

TASK [whatever] ****************************************************************
changed: [testrouter]

PLAY RECAP *********************************************************************
testrouter : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

And the result is the diff file:

tode@ubuntu:~/ansiblefolder$ cat R1diff.txt 
+ip access-list ext ansnap
+ permit ip 16.17.18.19 0.0.0.0 20.21.22.23 0.0.0.0

I’ll try to see what more I can do with this…

 

Nornir + Netmiko

To create this script, i got my .j2 and .yml acl files from the Netmiko script. I prepared the config.yaml file in the following fashion:

tode@ubuntu:~/nornirfolder$ cat config.yaml
---
core:
num_workers: 20

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

The files look as follows:

tode@ubuntu:~/nornirfolder/inventory$ cat hosts.yaml 
---
testrouter:
hostname: 192.168.122.100
port: 22
groups:
- cisco_ios
- testroutergroup
tode@ubuntu:~/nornirfolder/inventory$ cat groups.yaml 
---
testroutergroup:
groups:
- cisco_ios

cisco_ios:
platform: ios
tode@ubuntu:~/nornirfolder/inventory$ cat defaults.yaml 
---
username: cisco
password: cisco

Then it was quite easy once I worked out how to use the run method.

from nornir.plugins.tasks.networking import napalm_get
from nornir.plugins.functions.text import print_result
import json
from nornir.plugins.tasks import networking
import sys
from netmiko import ConnectHandler
import time 
from netmiko import Netmiko 
from jinja2 import Environment, FileSystemLoader 
import yaml


#this creates the acl_config string
env = Environment(loader = FileSystemLoader('.'), trim_blocks=True, autoescape=True) 
template = env.get_template('aclnr.j2') 
accesslists = yaml.load(open('aclnr.yml'), Loader=yaml.SafeLoader)
acl_config = template.render(data=accesslists) 
print(acl_config)

nr = InitNornir(config_file="./config.yaml")
task_result = nr.run(task=networking.netmiko_send_config, config_commands=acl_config.splitlines())
print_result(task_result)

 

Nornir + Napalm

A similar example, but with a file config and a manual connection def (i might need this later to do a manual jump through my proxy)

from nornir import InitNornir
from nornir.plugins.tasks.networking import napalm_get
from nornir.plugins.functions.text import print_result
import json
from nornir.plugins.tasks.networking import napalm_configure
from nornir.plugins.tasks import networking
import sys
from netmiko import ConnectHandler
import time 
from netmiko import redispatch 
from netmiko import Netmiko 
from jinja2 import Environment, FileSystemLoader 
import yaml


def task_manages_connection_manually(task):
task.host.open_connection("napalm", configuration=task.nornir.config)
r = task.run(
task=napalm_configure, filename='aclconfig', 
)
task.host.close_connection("napalm")
nr = InitNornir(config_file="./config.yaml")
task_result = nr.run(task=task_manages_connection_manually,)
print_result(task_result)

tode@ubuntu:~/nornirfolder$ python3 manualscript.py

 task_manages_connection_manually************************************************
* testrouter ** changed : True *************************************************
vvvv task_manages_connection_manually ** changed : False vvvvvvvvvvvvvvvvvvvvvvv INFO
---- napalm_configure ** changed : True ---------------------------------------- INFO
+ip access-list ext napalmconfg
+permit ip 4.7.9.11 0.0.0.0 5.3.1.2 0.0.0.0
^^^^ END task_manages_connection_manually ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

We can see that the lines have been added (+)

Achieving idempotency here can be done by writing the config file in exactly the same way as it will appear on the cisco router. I corrected the aclconfig file like this and ran the script once again. This time the status of napalm_configure task is: changed: False

ip access-list extended napalmconfg
 permit ip host 4.7.9.11 host 5.3.1.2

tode@ubuntu:~/nornirfolder$ python3 manualscript.py 

task_manages_connection_manually************************************************
* testrouter ** changed : False ************************************************
vvvv task_manages_connection_manually ** changed : False vvvvvvvvvvvvvvvvvvvvvvv INFO
---- napalm_configure ** changed : False --------------------------------------- INFO
^^^^ END task_manages_connection_manually ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tode@ubuntu:~/nornirfolder$

 

 

 

 

 

 

 

 

 

 

 

 

 

Skomentuj

Wprowadź swoje dane lub kliknij jedną z tych ikon, aby się zalogować:

Logo WordPress.com

Komentujesz korzystając z konta WordPress.com. Wyloguj /  Zmień )

Zdjęcie na Google

Komentujesz korzystając z konta Google. Wyloguj /  Zmień )

Zdjęcie z Twittera

Komentujesz korzystając z konta Twitter. Wyloguj /  Zmień )

Zdjęcie na Facebooku

Komentujesz korzystając z konta Facebook. Wyloguj /  Zmień )

Połączenie z %s