Configuring multi-area OSPF with Ansible

This time we're going to configure OSPF, but not with the CLI, we're going to use a network automation tool called Ansible. I'm going to walk through the steps how we can configure a relatively simple multi-area topology just with Ansible.

ansible_logo

Ansible is an agentless network automation tool, it doesn't require any special software to run on the managed nodes (aka. on the routers). It uses SSH as a transport to connect to the network devices and it pushes the desired configuration to them. We write the tasks in a YAML file called Playbook usually defining the end-state in a declarative approach, which then Ansible analyses and then takes the required actions and makes configuration changes. This time we're going to use GNS3 with the following topology:

ansible_topology

We're going to use a Linux machine as a Control Node which runs as a Docker container instance in GNS3 and already has Ansible installed. I've already configured the IP addresses on the devices and created a Mgmt VRF on all of the routers. These interfaces are in the common subnet 192.168.1.0/24, we're going to use these interfaces to SSH into the routers, because there's obviously no routing established yet between the routers. Notice that these interfaces are not in the global routing table, so the OSPF configuration won't affect them, we're only going to configure the interfaces in the global routing table. So first let's assign an IP address to the Control Node as well:

root@NetworkAutomation-1:~/ansible_lab# ifconfig eth0 192.168.1.100 netmask 255.255.255.0 up

Next we need to enable SSH on all routers, and create a user with privilege level 15. To enable SSH we have to generate an RSA cryptographic key pair first. To be able to do that we also have to assign a domain name to the routers first, this is how we do it on R1:

R1(config)#ip domain name test.com
R1(config)#crypto key generate rsa mod 2048
The name for the keys will be: R1.test.com

% The key modulus size is 2048 bits
% Generating 2048 bit RSA keys, keys will be non-exportable...
[OK] (elapsed time was 1 seconds)

%SSH-5-ENABLED: SSH 1.99 has been enabled

Next we enable SSH on the first five vty lines and we use the local database for authentication:

R1(config)#line vty 0 4
R1(config-line)#transport input ssh
R1(config-line)#login local
R1(config)#username ansible priv 15 secret cisco

Now we create the Inventory or the hosts file in other words on the Control Node, this is where we list all of the devices and the characteristics of the devices:

[routers]

R1 ansible_host=192.168.1.1
R2 ansible_host=192.168.1.2
R3 ansible_host=192.168.1.3
R4 ansible_host=192.168.1.4

[routers:vars]

ansible_connection=network_cli
ansible_network_os=ios
ansible_user=ansible
ansible_password=cisco

In this case I assign an alias to all to the routers: R1 is 192.168.1.1, R2 is 192.168.1.2 etc. In the [routers:vars] section we have some variables which are common for all of the devices: these are all Cisco IOS routers, and we use SSH to connect to the routers with the same username (ansible) and password (cisco). We can also write this in YAML if we want, here I choose to use the regular INI format.

Feel free to download this, and also every other YAML file related to this lab from my Git repository.

If we don't have all of the public SSH host keys from all of the device in the .ssh/known_hosts file on our Control Node, it can be advantageous to turn off the host_key_checking on the Linux host, otherwise Ansible won't run the playbooks. Ansible uses the following global configuration file: /etc/ansible/ansible.cfg, here we can turn it off:

[defaults]
host_key_checking=False

Just search for the line and uncomment it, or write it under the [defaults] section. Before writing the playbooks, let's check if we can actually reach our routers defined in the hosts file:

root@NetworkAutomation-1:~/ansible_lab# ansible routers -m ping -i hosts
R4 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
R1 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
R2 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
R3 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

We can also gather some information from our routers with the following command before running the actual playbook:

root@NetworkAutomation-1:~/ansible_lab# ansible routers -m ios_facts -i hosts
R1 | SUCCESS => {
    "ansible_facts": {
        "ansible_net_api": "cliconf",
        "ansible_net_gather_network_resources": [],
        "ansible_net_gather_subset": [
            "default"
        ],
        "ansible_net_hostname": "R1",
        "ansible_net_image": "flash0:/vios-adventerprisek9-m",
        "ansible_net_iostype": "IOS",
        "ansible_net_model": "IOSv",
        "ansible_net_operatingmode": "autonomous",
        "ansible_net_python_version": "3.8.5",
        "ansible_net_serialnum": "9H24ZY0WDM1GYI1FBLGYZ",
        "ansible_net_system": "ios",
        "ansible_net_version": "15.6(2)T",
        "ansible_network_resources": {}
    },
    "changed": false
}
R2 | SUCCESS => {
    "ansible_facts": {
        "ansible_net_api": "cliconf",
        "ansible_net_gather_network_resources": [],
        "ansible_net_gather_subset": [
            "default"
        ],
        "ansible_net_hostname": "R2",
        "ansible_net_image": "flash0:/vios-adventerprisek9-m",
        "ansible_net_iostype": "IOS",
        "ansible_net_model": "IOSv",
        "ansible_net_operatingmode": "autonomous",
        "ansible_net_python_version": "3.8.5",
        "ansible_net_serialnum": "99U0TV7PNS827AOMV8VWM",
        "ansible_net_system": "ios",
        "ansible_net_version": "15.6(2)T",
        "ansible_network_resources": {}
    },
    "changed": false
}
R3 | SUCCESS => {
    "ansible_facts": {
        "ansible_net_api": "cliconf",
        "ansible_net_gather_network_resources": [],
        "ansible_net_gather_subset": [
            "default"
        ],
        "ansible_net_hostname": "R3",
        "ansible_net_image": "flash0:/vios-adventerprisek9-m",
        "ansible_net_iostype": "IOS",
        "ansible_net_model": "IOSv",
        "ansible_net_operatingmode": "autonomous",
        "ansible_net_python_version": "3.8.5",
        "ansible_net_serialnum": "9A0INR4IAY5JPCZE1N7UI",
        "ansible_net_system": "ios",
        "ansible_net_version": "15.6(2)T",
        "ansible_network_resources": {}
    },
    "changed": false
}
R4 | SUCCESS => {
    "ansible_facts": {
        "ansible_net_api": "cliconf",
        "ansible_net_gather_network_resources": [],
        "ansible_net_gather_subset": [
            "default"
        ],
        "ansible_net_hostname": "R4",
        "ansible_net_image": "flash0:/vios-adventerprisek9-m",
        "ansible_net_iostype": "IOS",
        "ansible_net_model": "IOSv",
        "ansible_net_operatingmode": "autonomous",
        "ansible_net_python_version": "3.8.5",
        "ansible_net_serialnum": "9HDG0VKB6SJWEJNQ63JR7",
        "ansible_net_system": "ios",
        "ansible_net_version": "15.6(2)T",
        "ansible_network_resources": {}
    },
    "changed": false
}

Next let's write our playbook in YAML. If you're using a Linux machine without GUI, just as I do in this example, don't try to write YAML in vim or nano! Instead write it in VS Code or any kind of editor for syntax checking, indentation is very important in YAML, if you miss a space, Ansible simply won't run the playbook.

So we need the following requirements for the topology above: R4 should be configured as an ASBR, advertising the 0.0.0.0/0 default route as an E1 route with a metric of 4. There is no default route present in the RIB of R4, so we also need the always keyword as well, the command would be default-information originate always if we were using the CLI. R3 is just a regular backbone router with no additional configuration. R2 is an ABR, and we should configure area 1 as a Totally Not-So-Stubby area, so we need the no-summary keyword on the ABR. R1 is also an ASBR in the NSSA area which should redistribute his connected routes into the NSSA area. We have one additional connected route on R1 which is not advertised by OSPF: his loopback1 address which is 11.11.11.11/32. So after converging we should have an O N2 route on the ABR which will be converted into O E2 when it is injected into the backbone. Additionally on every router we set the reference bandwidth to 100 Gbps, so every interface should have a cost of 100.

---
- hosts: R4

  tasks:
    - name: Configuring OSPF on R4
      cisco.ios.ios_ospfv2:
        config:
          processes:
            - process_id: 1
              areas:
                - area_id: "0"
              network:
              - address: 0.0.0.0
                wildcard_bits: 255.255.255.255
                area: 0
              default_information:
                originate: true
                always: true
                metric_type: 1
                metric: 4
              auto_cost:
                reference_bandwidth: 100000

- hosts: R3
  tasks:
    - name: Configuring OSPF on R3
      cisco.ios.ios_ospfv2:
        config:
          processes:
            - process_id: 1
              areas:
                - area_id: "0"
              network:
              - address: 0.0.0.0
                wildcard_bits: 255.255.255.255
                area: 0
              auto_cost:
                reference_bandwidth: 100000

- hosts: R2
  tasks:
    - name: Configuring OSPF on R2
      cisco.ios.ios_ospfv2:
        config:
          processes:
            - process_id: 1
              areas:
              - area_id: "0"
              - area_id: "1"
                nssa:
                  no_summary: true
                  set: true
              network:
              - address: 10.0.23.0
                wildcard_bits: 0.0.0.255
                area: 0
              - address: 2.2.2.2
                wildcard_bits: 0.0.0.0
                area: 0
              - address: 10.0.12.0
                wildcard_bits: 0.0.0.255
                area: 1
              auto_cost:
                reference_bandwidth: 100000

- hosts: R1
  tasks:
    - name: Configuring OSPF on R1
      cisco.ios.ios_ospfv2:
        config:
          processes:
            - process_id: 1
              areas:
              - area_id: "1"
                nssa:
                  set: true
              network:
              - address: 10.0.12.0
                wildcard_bits: 0.0.0.255
                area: 1
              - address: 1.1.1.1
                wildcard_bits: 0.0.0.0
                area: 1
              auto_cost:
                reference_bandwidth: 100000
        state: overridden        
    - name: Redistributing connected routes on R1
      cisco.ios.ios_config:
        lines:
          - router ospf 1
          - redistribute connected subnets
      parents: router ospf 1

You shouldn't memorize all of these syntax, we just have to use the documentation of the cisco.ios.ios_ospfv2 module. It shows how we should do the indentation, what keyword we should use and also has some examples at the end. Besides the cisco.ios.ios_ospfv2 I also had to use another module called cisco.ios.ios_configfor the redistribution. Here we have all of the IOS modules. So let's run the playbook:

root@NetworkAutomation-1:~/ansible_lab# ansible-playbook playbook_ospf.yaml -i hosts

PLAY [R4] *******************************************************************************

TASK [Gathering Facts] *******************************************************************************
ok: [R4]

TASK [Configuring OSPF on R4] *******************************************************************************
changed: [R4]

PLAY [R3] *******************************************************************************

TASK [Gathering Facts] *******************************************************************************
ok: [R3]

TASK [Configuring OSPF on R3] *******************************************************************************
changed: [R3]

PLAY [R2] *******************************************************************************

TASK [Gathering Facts] *******************************************************************************
ok: [R2]

TASK [Configuring OSPF on R2] *******************************************************************************
changed: [R2]

PLAY [R1] *******************************************************************************

TASK [Gathering Facts] *******************************************************************************
ok: [R1]

TASK [Configuring OSPF on R1] *******************************************************************************
changed: [R1]

TASK [Redistributing connected routes on R1] *******************************************************************************
changed: [R1]

PLAY RECAP *******************************************************************************
R1                         : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
R2                         : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
R3                         : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
R4                         : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

It looks like Ansible successfully finished the tasks and made the configuration changes.

Verfication

Now let's use the CLI and take a look at the OSPF configuration of each router:

R1#show run | sec ospf
router ospf 1
 auto-cost reference-bandwidth 100000
 area 1 nssa
 redistribute connected subnets
 network 1.1.1.1 0.0.0.0 area 1
 network 10.0.12.0 0.0.0.255 area 1

R2#show run | sec ospf
router ospf 1
 auto-cost reference-bandwidth 100000
 area 1 nssa no-summary
 network 2.2.2.2 0.0.0.0 area 0
 network 10.0.12.0 0.0.0.255 area 1
 network 10.0.23.0 0.0.0.255 area 0

R3#show run | sec ospf
router ospf 1
 auto-cost reference-bandwidth 100000
 network 0.0.0.0 255.255.255.255 area 0

R4#show run | sec ospf
router ospf 1
 auto-cost reference-bandwidth 100000
 network 0.0.0.0 255.255.255.255 area 0
 default-information originate always metric 4 metric-type 1

This is what we wanted to configure with the YAML file. Let's also verify the LSDBs and the routing tables. R1 should generate a single Type-7 NSSA external LSA for the 11.11.11.11/32 prefix:

 
R1#show ip ospf database nssa-external 
 
            OSPF Router with ID (11.11.11.11) (Process ID 1)
 
               Type-7 AS External Link States (Area 1)
 
  LS age: 513
  Options: (No TOS-capability, Type 7/5 translation, DC, Upward)
  LS Type: AS External Link
  Link State ID: 11.11.11.11 (External Network Number )
  Advertising Router: 11.11.11.11
  LS Seq Number: 80000001
  Checksum: 0x3EFB
  Length: 36
  Network Mask: /32
        Metric Type: 2 (Larger than any link state path)
        MTID: 0 
        Metric: 20 
        Forward Address: 1.1.1.1
        External Route Tag: 0

R1 should also have a single Type-3 Summary LSA for the 0.0.0.0/0 default route from the ABR (R2) because we configured the NSSA with the no-summary keyword:

 
R1#show ip ospf database summary 
 
            OSPF Router with ID (11.11.11.11) (Process ID 1)
 
                  Summary Net Link States (Area 1)
 
  LS age: 552
  Options: (No TOS-capability, DC, Upward)
  LS Type: Summary Links(Network)
  Link State ID: 0.0.0.0 (summary Network Number)
  Advertising Router: 2.2.2.2
  LS Seq Number: 80000001
  Checksum: 0xFC31
  Length: 28
  Network Mask: /0
        MTID: 0       Metric: 1 
 
R1 seems to be alright, move on to R2, and let's just check the RIB: we should have an O N2 route from R1 for 11.11.11.11/32 with a default cost of 20, and we should have an O E1 route from R4 for 0.0.0.0/0 with a cost of 204 (100 to get to R3 + 100 to get to R4 + 4 for the default route):
 
R2#show ip route ospf | begin Gateway
Gateway of last resort is 10.0.23.3 to network 0.0.0.0

O*E1  0.0.0.0/0 [110/204] via 10.0.23.3, 00:01:26, GigabitEthernet0/1
      1.0.0.0/32 is subnetted, 1 subnets
O        1.1.1.1 [110/101] via 10.0.12.1, 00:01:16, GigabitEthernet0/0
      3.0.0.0/32 is subnetted, 1 subnets
O        3.3.3.3 [110/101] via 10.0.23.3, 00:01:26, GigabitEthernet0/1
      4.0.0.0/32 is subnetted, 1 subnets
O        4.4.4.4 [110/201] via 10.0.23.3, 00:01:26, GigabitEthernet0/1
      10.0.0.0/8 is variably subnetted, 5 subnets, 2 masks
O        10.0.34.0/24 [110/200] via 10.0.23.3, 00:01:26, GigabitEthernet0/1
      11.0.0.0/32 is subnetted, 1 subnets
O N2     11.11.11.11 [110/20] via 10.0.12.1, 00:01:16, GigabitEthernet0/0
 
Let's also verify the LSDB on R4: this router should have two Type-5 External LSAs in his LSDB. One Type-1 for 0.0.0.0/0 with a cost of 4 which he generates himself, and one Type-2 for 11.11.11.11/32 with a cost of 20 which the ABR generates:
 
R4#show ip ospf database external 
 
            OSPF Router with ID (4.4.4.4) (Process ID 1)
 
                  Type-5 AS External Link States
 
  LS age: 801
  Options: (No TOS-capability, DC, Upward)
  LS Type: AS External Link
  Link State ID: 0.0.0.0 (External Network Number )
  Advertising Router: 4.4.4.4
  LS Seq Number: 80000001
  Checksum: 0x5DC2
  Length: 36
  Network Mask: /0
        Metric Type: 1 (Comparable directly to link state metric)
        MTID: 0 
        Metric: 4 
        Forward Address: 0.0.0.0
        External Route Tag: 1
 
  LS age: 738
  Options: (No TOS-capability, DC, Upward)
  LS Type: AS External Link
  Link State ID: 11.11.11.11 (External Network Number )
  Advertising Router: 2.2.2.2
  LS Seq Number: 80000001
  Checksum: 0xE186
  Length: 36
  Network Mask: /32
        Metric Type: 2 (Larger than any link state path)
        MTID: 0 
        Metric: 20 
        Forward Address: 1.1.1.1
        External Route Tag: 0

So we can confirm that OSPF has converged and we have the LSAs which we wanted.

Setting the DNS servers for all routers

Let's do one more modification, we'll configure two DNS servers for all of the routers with another playbook. This will be common for all of the routers, so we just define the hosts as the routers which contains all four routers:

---
- hosts: routers

tasks:
    - name: Configuring DNS servers on all routers
      cisco.ios.ios_system:
        name_servers:
          - 1.1.1.1
          - 8.8.8.8

And let's run the playbook:

root@NetworkAutomation-1:~/ansible_lab#  ansible-playbook playbook_dns.yaml -i hosts

PLAY [routers] *******************************************************************************

TASK [Gathering Facts] *******************************************************************************
ok: [R4]
ok: [R1]
ok: [R3]
ok: [R2]

TASK [Configuring DNS servers on all routers] *******************************************************************************
changed: [R2]
changed: [R1]
changed: [R4]
changed: [R3]

PLAY RECAP *******************************************************************************
R1                         : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
R2                         : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
R3                         : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
R4                         : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Ansible made some changes according to the output, let's verify on R1:

R1#show run | sec name-server
ip name-server 1.1.1.1
ip name-server 8.8.8.8

Indeed, now we have two DNS servers configured on all of the routers. These are the situation where Ansible can be really powerful, imagine we have set the same DNS or NTP servers for hundreds or thousands of routers. You don't have to SSH into all of the routers, and type the same configuration like thousand times. You just run a playbook with a few lines in a YAML file with a single task for all of the routers.