The fourth part of my ongoing series of posts on Ansible for Networking will cover Juniper’s JunOS. You can view the other posts in the series below: -

All the playbooks, roles and variables used in this article are available in my Network Automation with Ansible repository

Why JunOS?

Juniper are often placed in the top 3 or 4 networking vendors in terms of market share (along with Cisco, HPE/Aruba and Huawei). They are especially popular in the service provider sector.

My current workplace has an almost entirely Juniper-based network (save for a few Cisco management and access switches), so this article not only helps those using Juniper, but may also help with automating our network too.

Unlike Cisco, Juniper do not have different operating systems across their firewalls, routing and switching (data centre or access) portfolio. Instead, they all run JunOS. Some features are not enabled on some platforms, for example firewall policies on switches, or ethernet switching on routers. However most other features are common.

To configure BGP on Cisco devices, the syntax differs between IOS-XR and IOS-XE. Configuring OSPF is not the same on NX-OS and IOS-XR. However on JunOS, it is the same across all of their hardware.

JunOS has its roots in FreeBSD, although the configuration is done in a vendor-specific CLI rather than within the FreeBSD base. If you run start shell though, you will be taken into a FreeBSD shell. While it isn’t a fully featured desktop operating system, it still has many commands you may be familiar with (e.g. ifconfig, tcpdump).

One of the biggest selling points of JunOS is the commit-style configuration. When you apply configuration, it is not activated immediately. Instead you can choose to either keep working on the candidate configuration, check it (using commit check), or commit it.

You also have the option of using commit confirmed. With this, all changes will be rolled backed (after a short period of time) if you do not confirm your commit (i.e. type commit again). You can specify the period of time, or just type commit confirmed on its own, which will roll back changes after 5 minutes.

In Cisco IOS, it does not have this feature. You either have to run your changes in such a way that you cannot lose access, a dedicated out-of-band solution (usually via a serial console), use reload in x (x being minutes, rebooting the router after that amount of time) on every change, or you have to hope a field engineer can visit site within your SLA.

Cisco appear to have taken note of this. In Cisco’s IOS-XR (their operating system geared towards service providers), they have adopted the commit-based system of managing configuration rather than immediately applying changes.

Configuration style

The configuration style for JunOS differs from IOS. Rather than entering different hierarchical “levels” to apply changes, you can apply them all from one level.

For example, in IOS you need to type interface Gi0/0/0 first before you can then run ip address 192.168.0.1 255.255.255.0. For JunOS, this would be set interface ge-0/0/0 unit 0 family inet address 192.168.0.1/24.

All interfaces have units, meaning that all configuration appears as if it is using a sub-interface. What this means in practice is that your Layer 3 configuration will always be part of a unit (usually unit 0).

You can choose to enter a level of hierarchy too, so that multiple commands at the same level can be applied: -

ansible@junos-01# edit interfaces fxp0 

[edit interfaces fxp0]
ansible@junos-01# set unit 0 description Management

[edit interfaces fxp0]
ansible@junos-01# set unit 0 family inet address 10.15.30.33/24

You can view the configuration in multiple ways. There is a JSON-like syntax, as seen below: -

ansible@junos-01> show configuration snmp 
v3 {
    usm {
        local-engine {
            user yetiops {
                authentication-sha {
                    authentication-key "###REDACTED###"; ## SECRET-DATA
                }
                privacy-aes128 {
                    privacy-key "###REDACTED###"; ## SECRET-DATA
                }
            }
        }
    }
[...]

Alternatively, you can choose to output this in true JSON format: -

ansible@junos-01> show configuration snmp | display json 
{
    "configuration" : {
        "@" : {
            "junos:commit-seconds" : "1584185530", 
            "junos:commit-localtime" : "2020-03-14 11:32:10 UTC", 
            "junos:commit-user" : "ansible"
        }, 
        "snmp" : {
            "v3" : {
                "usm" : {
                    "local-engine" : {
                        "user" : [
[...]

Another option is to show the actual commands used for configuration: -

ansible@junos-01> show configuration snmp | display set 
set snmp v3 usm local-engine user yetiops authentication-sha authentication-key "###REDACTED###"
set snmp v3 usm local-engine user yetiops privacy-aes128 privacy-key "###REDACTED###"
set snmp v3 vacm security-to-group security-model usm security-name yetiops group yetiops_group
set snmp v3 vacm access group yetiops_group default-context-prefix security-model any security-level authentication read-view all
set snmp v3 vacm access group yetiops_group default-context-prefix security-model any security-level authentication write-view all
set snmp view all oid .1

This is especially useful for when you want to plan changes, and need to essentially replicate sections with minor updates to them.

Objectives

For each vendor, I will be using Ansible to configure two routers/switches/firewalls/appliances.

One will serve as the Edge router, connecting to the Internet and also via BGP to the Net Server. The Net Server is a CentOS 8 Virtual Machine acting as a route server, syslog collector and TACACS+ server (detailed in The Lab Environment)

The other will be an internal router, performing core functions (i.e. internal routing rather than external).

This lab is based upon the Juniper vSRX platform (virtualised SRX firewalls).

Edge router

The edge router will run the following: -

  • External BGP (eBGP) to the Net Server
    • Advertising internal networks
  • Internal BGP (iBGP) to the Internal router
    • Advertising any routes received from the Net Server
    • Advertising a default route (for internet access)
  • OSPF
    • Advertising loopbacks and internal networks between both routers
  • IPv4 and IPv6 routing
    • Using OSPFv3 (for IPv6 support)
    • Using the IPv6 Address Family for BGP
  • SNMPv3 for monitoring
  • IPv4 NAT to allow internet access
    • I cannot run IPv6 for internet access, as my current ISP does not support IPv6
  • Logging via Syslog to the Net Server
  • Authentication, Authorization and Accounting (AAA) via TACACS+ to the Net Server
  • Zones to place interfaces in, for zone-based firewalling
  • Firewall Rules to allow traffic to/from the Net Server, and between the two routers

As we are using firewalls rather than routers, we are able to leverage a lot of firewall features (like zones and full firewall rules) compared to most router platforms.

Internal router

The internal router runs a subset of the functions that the edge router does: -

  • Internal BGP (iBGP) to the Edge router
    • Receiving any routes received from the Net Server
    • Receiving a default route (for internet access)
  • OSPF
    • Advertising loopbacks and internal networks between both routers
  • IPv4 and IPv6 routing
    • Using OSPFv3 (for IPv6 support)
    • Using the IPv6 Address Family for BGP
  • SNMPv3 for monitoring
  • Logging via Syslog to the Net Server
  • Authentication, Authorization and Accounting (AAA) via TACACS+ to the Net Server
  • Zones to place interfaces in, for zone-based firewalling
  • Firewall Rules to allow traffic to/from the Net Server, and between the two routers

No firewalling or filtering was used on the internal router previously, but in this case we are going to use it to show some of the concepts of Juniper firewalling and services.

Netconf

Nearly all of the Juniper Ansible modules use netconf rather than the network_cli module. Netconf uses XML over SSH to interact with the destination host rather than using CLI commands over SSH, network_cli utilizing the latter.

This means that the data being returned to Ansible is in a structured format (e.g. error messages are in a consistent format and consistent structure). By contrast, The network_cli connection plugin is interpreting the error messages returned. This can result in issues with modules if the vendor changes the text in an error message for example.

When using modules, this makes very little difference to the tasks you’ll create. Where it does make a difference is the error messages in response to any failed tasks or plays. You may need to use the -vvvv option with ansible-playbook to see the error messages, as they are much more verbose.

Again, Python is not running on the destination hosts themselves, so the host running Ansible is proxying the XML requests via itself.

Prerequisites

To be able to manage a JunOS device with Ansible, a few manual configuration steps are required, and also some changes to the default Ansible connection configuration is required.

Ansible Configuration

The following defaults are required to use Ansible with JunOS: -

ansible_user: ansible
ansible_connection: netconf 
ansible_network_os: junos
ansible_ssh_pass: !vault |
            $ANSIBLE_VAULT;1.1;AES256
            383###REDACTED###############################################################566
            363###REDACTED###############################################################366
            343###REDACTED###############################################################565
            326###REDACTED###############################################################362
            3431

There is no enable mode like in Cisco IOS. Privilege escalation is controlled at a user or group level, without the need for an additional mode to run certain commands. Therefore we do not need to supply any of the ansible_become statements as we do for IOS.

The ansible_connection plugin has changed to netconf. There are only a couple of JunOS modules which support network_cli, and one of them is used to configure netconf for you!

Also, Ansible relies on the Python module ncclient, so you’ll need to install this either with your chosen operating systems package manager, or with pip install ncclient

Caveats

The ansible_ssh_pass may not be required in your environment. JunOS does support public SSH keys (for both standard SSH connectivity and netconf).

I recently decided to move the network lab to a dedicated machine (a Dell Optiplex 3020, which I will detail in another post) running Ubuntu 18.04.4. This means I can now run the network labs without having a portable space heater on my lap!

However, despite running the same Ansible version and ncclient version as my Manjaro-based laptop, the Ubuntu machine could not connect via netconf using SSH keys. I suspect this is due to Manjaro using Python 3.8, compared to Ubuntu 18.04.4’s Python 3.6. Ubuntu 20.04 is not far away as I write this post, so hopefully when I upgrade, this issue will be resolved.

Juniper JunOS Configuration

To allow Ansible access to manage a JunOS device via netconf you need to create a user with super-user privileges and enable netconf. Also when you try to commit for the first time on a new JunOS device (or one with the default configuration), you’ll be prompted for a password for the root account.

If you login from the console (virsh console vsrx-01 in my case), you can login to the device with the username root and no password.

Below shows how to enable all of this: -

## The root account is placed into the FreeBSD shell by default
## Type cli to get into the JunOS shell
root@% cli
root@> 

## Go into configuration mode
root@> configure
Entering configuration mode

[edit]
root@#

## Add the user
root@# set system login user ansible authentication plain-text-password
New password:
Retype new password:

[edit]
root@# set system login user ansible class super-user

## Enable netconf
[edit]
root@# set system services netconf ssh port 830

## Configure a root password 
[edit]
root@# set system root-authentication plain-text-password

## Add an IP to the management interface
[edit]
root@# set interfaces fxp0 unit 0 family inet address 10.15.30.33/24

## Set the hostname
[edit]
root@# set system host-name junos-01 

## Commit the configuration
[edit]
root@# commit

As you can see, the syntax style is very different from Cisco IOS. The configuration appears more verbose, but it also separates configuration into well defined sections.

For example, all system configuration is defined within the set system syntax, whereas all protocol configuration is prefixed by set protocol (e.g. set protocol bgp, set protocol ospf).

Once the above is committed, add the device into your Ansible inventory. My inventory file looks like the below: -

[junos]
junos-01 ansible_host=10.15.30.33
junos-02 ansible_host=10.15.30.34

Enabling IPv6 Flows

To allow firewalling of IPv6 traffic, you need to enable the flow-based option for IPv6 forwarding. This means that rather than trying to evaluate every single packet, traffic is matched in flows.

For example, if you have traffic that has the same source IP, destination IP, source port and destination port, this would be considered a flow of traffic. The flow is established when the first packet is sent, and then subsequent packets match the flow and are treated the identically to the first packet.

Without this, every subsequent packet would have to go through every single firewall rule again to find a match. With flows enabled, only the first packet in the session needs to go through the evaluation of the rules.

This saves time on every packet, and also cuts down on processor usage, as you are reducing the amount of CPU cycles required to evaluate every single packet traversing the device.

To enable this for IPv6, you enter the command set security forwarding-options family inet6 mode flow-based. This does require rebooting the device after it is enabled, so it would be best to do this before applying any of the subsequent playbooks and configuration.

This is already enabled by default for IPv4, so you do not need to set this manually.

Verification

Can we contact both devices?

$ ansible junos -m junos_facts --ask-vault-pass | grep -i hostname
Vault password: 
        "ansible_net_hostname": "junos-01",
        "ansible_net_hostname": "junos-02",

Setup

The setup is identical to the IOS lab, with a management interface to access the devices, a VLAN bridge for inter-device communication, and an interface on the edge router attached to the KVM NAT bridge for DHCP/Internet access.

VLANs, IP addressing and Autonomous System numbers

The ID chosen for Juniper JunOS is 02.

VLANs

The VLANs used will be: -

  • VLAN102 between the edge router and netsvr-01
  • VLAN202 between the edge router and internal router

IP Addressing

  • IPv4 Subnet on VLAN102: 10.100.102.0/24
    • edge router - 10.100.102.253/24
    • netsvr-01 - 10.100.102.254/24
  • IPv4 Subnet on VLAN202: 10.100.202.0/24
    • edge router - 10.100.202.254/24
    • internal router - 10.100.202.253/24
  • IPv6 Subnet on VLAN102: 2001:db8:102::/64
    • edge router - 2001:db8:102::f/64
    • netsvr-01 - 2001:db8:102:ffff/64
  • IPv6 Subnet on VLAN202: 2001:db8:202::/64
    • edge router - 2001:db8:202::a/64
    • internal router - 2001:db8:202:f/64
  • IPv4 Loopback Addressing
    • edge router - 192.0.2.102/32
    • internal router - 192.0.2.202/32
  • IPv6 Loopback Address
    • edge router - 2001:db8:902:beef::1/128
    • internal router - 2001:db8:902:beef::2/128

BGP Autonomous System

The BGP Autonomous System number will be AS65102.

Configuration

System tasks

As per the Cisco IOS lab, this role removes unneeded banners, creates a new banner, and enables syslog logging to the netsvr-01 machine.

We do not need to enable any form of password encryption, as passwords are encrypted by default in JunOS configuration.

Playbook

The contents of the Playbook can be seen below: -

---
## tasks file for system
- name: Set hostname
  junos_system:
    hostname: "{{ inventory_hostname }}"

- name: Remove unneeded banners
  junos_banner:
    banner: "{{ item }}"
    state: absent
  loop:
  - motd

- name: Update login banner
  junos_banner:
    banner: login
    text: |
      ----------------------------------------
      |
      | This banner was generated by Ansible 
      |
      ----------------------------------------
      |
      | You are logged into {{ inventory_hostname }}
      | 
      ----------------------------------------
    state: present

- name: Configure syslog
  junos_logging:
    dest: host
    name: "{{ log_host }}"
    level: info
    facility: any
    state: present

In this, we also set the hostname of the device, if not already set. This playbook is remarkably similar to the IOS playbook. Anywhere that we have used an ios_* module before, we use a junos_* module (e.g. junos_banner instead of ios_banner).

Setting Hostname

Ansible module: junos_system

This task sets the hostname of the device. The generated configuration is below: -

set system host-name junos-01
Removing unneeded banners

Ansible module: junos_banner

This removes any banners that are set, other than the login banner. In this case, it just removes the motd banner.

Update the login banner

Ansible module: junos_banner

This task generates a banner for when you login to a device, which references the hostname. As in the Cisco IOS version, you could use a template file to generate this, especially if you have to provide specific information legally or for compliance reasons.

The generated configuration looks like the below: -

set system login message "----------------------------------------\n|\n| This banner was generated by Ansible \n|\n----------------------------------------\n|\n| You are logged into junos-01\n| \n----------------------------------------"

When you login to the device, this looks like: -

$ ssh [email protected]
----------------------------------------
|
| This banner was generated by Ansible 
|
----------------------------------------
|
| You are logged into junos-01
| 
----------------------------------------
Password:
Configure syslog

Ansible module: junos_logging

This task configures logging to syslog, using a variable called log_host. This variable is defined in the group_vars file: -

$ cat group_vars/junos | grep -i log
log_host: 10.100.101.254

We also use the facility any, and the level of info. The facility refers to what kind of logs are being sent (it could be all logs, or it could be those specific to VPNs for example). The level refers to the verbosity of the logs being sent (the higher the level, the more logging will be sent).

The generated configuration is below: -

set system syslog host 10.100.102.254 any info

Interfaces

This role configures all the interfaces being used on the JunOS device. As mentioned, all Layer 3 configuration (i.e. IP addressing) is configured under a unit, so effectively every interface with an IP address is a sub-interface.

Playbook

The contents of the Playbook are: -

---
## tasks file for interfaces
- name: Configure subinterfaces first
  junos_config:
    src: subints.j2
    src_format: set

- name: Configure interfaces - Status and Descriptions
  junos_interfaces:
    config:
      - name: "{{ item.junos_if }}"
        description: "{{ item.desc }}"
        enabled: "{{ item.enabled }}"
  when: item.unit == 0
  loop: "{{ interfaces }}"

- name: Configure subinterfaces - Descriptions
  junos_config:
    src: descs.j2
    src_format: set

- name: Configure interfaces - L3 IPv4
  junos_l3_interfaces:
    config:
      - name: "{{ item.junos_if }}"
        unit: "{{ item.unit }}"
        ipv4:
        - address: "{{ item.ipv4_addr }}"
  when: 
    - item.ipv4_addr is defined
  loop: "{{ interfaces }}"

- name: Configure interfaces - L3 IPv6
  junos_l3_interfaces:
    config:
      - name: "{{ item.junos_if }}"
        unit: "{{ item.unit }}"
        ipv6:
        - address: "{{ item.ipv6_addr }}"
  when:
    - item.ipv6_addr is defined
  loop: "{{ interfaces }}"

What you’ll notice here is that we have a task for setting interface status and descriptions, but also one just below it for setting the descriptions of sub-interfaces too.

This is necessary because unfortunately the junos_interfaces module does not support units, so you cannot set the descriptions on them.

Configuring sub-interfaces

Ansible module: junos_config

As mentioned above, the junos_interfaces module (at the time of writing) does not support sub-interfaces (and therefore adding VLANs to an underlying interface).

Instead, we will use the junos_config module. This module is functionally identical to the ios_config module, in that you supply a configuration template or individual lines of configuration to be applied.

The src_format option is used to specify the format of configuration used in your templates. You can use xml, text, json or set. The set option is configuration like set interface *****.

The template being applied is below: -

{% for interface in interfaces %}
{% if interface['subint'] is defined %}
set interfaces  {{ interface['junos_if'] }} vlan-tagging
{% for vlan in interface['subint']['vlans'] %}
set interfaces {{ interface['junos_if'] }} unit {{ vlan }} vlan-id {{ vlan }}
{% endfor %}
{% endif %}
{% endfor %} 

This template sets the underlying interface (i.e. the interface which passes VLANs) to vlan-tagging mode (what Cisco would call a “trunked” port). It also creates the interface units, associating them with the VLANs defined in our host_vars.

Our host_vars look like the below: -

interfaces:
  - junos_if: "ge-0/0/0"
    unit: 0
    desc: "VLAN Bridge"
    enabled: "true"
    subint:
      vlans:
      - 102
      - 202

The configuration this generates is: -

set interfaces ge-0/0/0 vlan-tagging
set interfaces ge-0/0/0 unit 102 vlan-id 102
set interfaces ge-0/0/0 unit 202 vlan-id 202

This means that when we apply configuration (e.g. IP address) to ge0/0/0.102 and ge-0/0/0.202, they will use VLAN tags 102 and 202 respectively.

An unfortunate caveat is that we also need to create unit 0 with a VLAN ID when vlan-tagging is used. Our configuration template says that the VLAN ID matches the unit ID, but VLAN 0 is not a valid VLAN ID.

Therefore, we have to manually add set interfaces ge-0/0/0 unit 0 vlan-id 1 to make sure that the rest of the configuration works as expected. Once the Ansible JunOS modules support units properly, we can remove this element of manual configuration.

Status and descriptions (not subinterfaces)

Ansible module: junos_interfaces

The descriptions and statuses are of our physical interfaces (rather than the logical interface units) are controlled with this task.

This is done by only matching for interfaces in our host_vars that have a unit of 0 (i.e. the base/default unit for the interface). For example, the below is a summarized version of the host_vars for junos-01: -

  - junos_if: "fxp0"
    unit: 0
    desc: "Management"
  - junos_if: "ge-0/0/0"
    unit: 0
    desc: "VLAN Bridge"
  - junos_if: "ge-0/0/0"
    unit: 102
    desc: "To netsvr"
  - junos_if: "ge-0/0/0"
    unit: 202
    desc: "To junos-02"
  - junos_if: "ge-0/0/1"
    desc: "To the Internet"
    unit: 0
  - junos_if: "lo0"
    unit: 0
    desc: "Loopback"

We therefore apply configuration to: -

  • fxp0
  • ge-0/0/0
  • ge-0/0/1
  • lo0

The following configuration is generated: -

set interfaces ge-0/0/0 description "VLAN Bridge"
set interfaces ge-0/0/1 description "To the Internet"
set interfaces fxp0 description Management
set interfaces lo0 description Loopback

The descriptions are not applied to unit 0 of each interface because of the lack of unit support in junos_interfaces. For this we need another task.

Status and descriptions (subinterfaces)

Ansible module: junos_config

This module is used to update the descriptions of every sub-interface. The template looks like the following: -

{% for interface in interfaces %}
set interfaces {{ interface['junos_if'] }} unit {{ interface['unit'] }} description "{{ interface['desc'] }}"
{% endfor %} 

With the host_vars mentioned in the previous task, this would generate the following configuration: -

set interfaces ge-0/0/0 unit 0 description "VLAN Bridge"
set interfaces ge-0/0/0 unit 102 description "To netsvr"
set interfaces ge-0/0/0 unit 202 description "To junos-02"
set interfaces ge-0/0/1 unit 0 description "To the Internet"
set interfaces fxp0 unit 0 description Management
set interfaces lo0 unit 0 description Loopback

If you compare this to the previous task, we unfortunately create duplicate descriptions (i.e. on the underlying physical interface, and unit 0). We could run this module only on interfaces that have non-zero units, but this would create issues for the next task.

IPv4 addressing

Ansible module: junos_l3_interfaces

This works similarly to the Cisco IOS task, in that it applies an IPv4 address to an interface (to whatever unit is defined in our host_vars).

However, there is a caveat. The JunOS module appears to make an attempt at gathering the existing configuration on the interface units before making any changes. If a unit do not exist to apply the addresses to (i.e. no previous task created them), then the module fails.

This differs from Cisco IOS. Our Cisco IOS task created sub-interfaces if they did not exist during the process of adding IPv4 (or IPv6) addressing to them.

This unfortunately means that until the junos_interfaces module supports units, or the junos_l3_interfaces module can create interfaces, we need a series of workarounds to allow us to use VLANs, descriptions on all our interfaces and IP addressing.

The generated configuration from this module is below: -

set interfaces ge-0/0/0 unit 102 family inet address 10.100.102.253/24
set interfaces ge-0/0/0 unit 202 family inet address 10.100.202.254/24
set interfaces ge-0/0/1 unit 0 family inet dhcp
set interfaces fxp0 unit 0 family inet address 10.15.30.33/24
set interfaces lo0 unit 0 family inet address 192.0.2.102/32

Unlike Cisco IOS, JunOS uses the CIDR address format (i.e. X.X.X.X/Y) natively, rather than using subnet mask.

It also supports the dhcp keyword, just like the Cisco IOS module does.

IPv6 addressing

Ansible module: junos_l3_interfaces

This is identical to the above task, except we are applying IPv6 addressing.

The output of the task is below: -

set interfaces ge-0/0/0 unit 102 family inet6 address 2001:db8:102::f/64
set interfaces ge-0/0/0 unit 202 family inet6 address 2001:db8:202::a/64
set interfaces lo0 unit 0 family inet6 address 2001:db8:902:beef::1/128

Verification

junos-01

! Show IPs (IPv4 and IPv6)
ansible@junos-01> show interfaces terse | match "inet|inet6" 
ge-0/0/0.102            up    up   inet     10.100.102.253/24
                                   inet6    2001:db8:102::f/64
ge-0/0/0.202            up    up   inet     10.100.202.254/24
                                   inet6    2001:db8:202::a/64
ge-0/0/1.0              up    up   inet     192.168.122.23/24
fxp0.0                  up    up   inet     10.15.30.33/24  
lo0.0                   up    up   inet     192.0.2.102         --> 0/0
                                   inet6    2001:db8:902:beef::1

! Show interface statuses and descriptions
!! Notice the duplicate descriptions
ansible@junos-01> show interfaces descriptions 
Interface       Admin Link Description
ge-0/0/0        up    up   VLAN Bridge
ge-0/0/0.0      up    up   VLAN Bridge
ge-0/0/0.102    up    up   To netsvr
ge-0/0/0.202    up    up   To junos-02
ge-0/0/1        up    up   To the Internet
ge-0/0/1.0      up    up   To the Internet
fxp0            up    up   Management
fxp0.0          up    up   Management
lo0             up    up   Loopback
lo0.0           up    up   Loopback

! Ping to netsvr-01 on IPv4 and IPv6
ansible@junos-01> ping 10.100.102.254 
PING 10.100.102.254 (10.100.102.254): 56 data bytes
64 bytes from 10.100.102.254: icmp_seq=0 ttl=64 time=5.430 ms
^C
--- 10.100.102.254 ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max/stddev = 5.430/5.430/5.430/0.000 ms

ansible@junos-01> ping 2001:db8:102::ffff 
PING6(56=40+8+8 bytes) 2001:db8:102::f --> 2001:db8:102::ffff
16 bytes from 2001:db8:102::ffff, icmp_seq=0 hlim=64 time=7.451 ms
16 bytes from 2001:db8:102::ffff, icmp_seq=1 hlim=64 time=0.425 ms
^C
--- 2001:db8:102::ffff ping6 statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/std-dev = 0.425/3.938/7.451/3.513 ms

! Ping to junos-02 on IPv4 and IPv6
ansible@junos-01> ping 10.100.202.253 
PING 10.100.202.253 (10.100.202.253): 56 data bytes
64 bytes from 10.100.202.253: icmp_seq=0 ttl=64 time=0.547 ms
^C
--- 10.100.202.253 ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.547/0.547/0.547/0.000 ms

ansible@junos-01> ping 2001:db8:202::f    
PING6(56=40+8+8 bytes) 2001:db8:202::a --> 2001:db8:202::f
16 bytes from 2001:db8:202::f, icmp_seq=0 hlim=64 time=0.529 ms
16 bytes from 2001:db8:202::f, icmp_seq=1 hlim=64 time=0.513 ms
^C
--- 2001:db8:202::f ping6 statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/std-dev = 0.513/0.521/0.529/0.008 ms

junos-02

! Show IPs (IPv4 and IPv6)
ansible@junos-02> show interfaces terse | match "inet|inet6" 
ge-0/0/0.202            up    up   inet     10.100.202.253/24
                                   inet6    2001:db8:202::f/64
sp-0/0/0.0              up    up   inet    
                                   inet6   
sp-0/0/0.16383          up    up   inet    
em0.0                   up    up   inet     128.0.0.1/2     
em1.32768               up    up   inet     192.168.1.2/24  
fxp0.0                  up    up   inet     10.15.30.34/24  
lo0.0                   up    up   inet     192.0.2.202         --> 0/0
                                   inet6    2001:db8:902:beef::2
lo0.16384               up    up   inet     127.0.0.1           --> 0/0
lo0.16385               up    up   inet     10.0.0.1            --> 0/0

! Show interface statuses and descriptions
!! Notice the duplicate descriptions
ansible@junos-02> show interfaces descriptions 
Interface       Admin Link Description
ge-0/0/0        up    up   VLAN Bridge
ge-0/0/0.0      up    up   VLAN Bridge
ge-0/0/0.202    up    up   To junos-01
fxp0            up    up   Management
fxp0.0          up    up   Management
lo0             up    up   Loopback
lo0.0           up    up   Loopback

! Ping to junos-01 on IPv4 and IPv6
ansible@junos-02> ping 10.100.202.253 
PING 10.100.202.253 (10.100.202.253): 56 data bytes
64 bytes from 10.100.202.253: icmp_seq=0 ttl=64 time=0.645 ms
^C
--- 10.100.202.253 ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.645/0.645/0.645/0.000 ms

ansible@junos-02> ping 2001:db8:202::a   
PING6(56=40+8+8 bytes) 2001:db8:202::f --> 2001:db8:202::a
16 bytes from 2001:db8:202::a, icmp_seq=0 hlim=64 time=1.724 ms
16 bytes from 2001:db8:202::a, icmp_seq=1 hlim=64 time=1.380 ms
16 bytes from 2001:db8:202::a, icmp_seq=2 hlim=64 time=1.272 ms
^C
--- 2001:db8:202::a ping6 statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max/std-dev = 1.272/1.459/1.724/0.193 ms

Looking good so far!

Firewall

As we’re using the Juniper vSRX images, we can make use of zone-based firewalling. In the Cisco IOS lab, we only used access lists, and only used them on the interface between the edge router and netsvr-01.

In this, we are building firewall policies, placing all used interfaces in zones, and applying rules on both the edge router (junos-01) and the internal router (junos-02).

A point to note with firewalling compared to access lists is that most firewalls are stateful. This means that you only need to define a rule for traffic flowing from source to destination. A “state” (i.e. an entry of where the traffic is coming from and going to) is created, and will automatically match return traffic.

For example, if you SSH to a server, your SSH client will use a randomized source port (e.g. TCP47846), and a destination port of TCP22 (unless SSH is running on a different port of course!). The return traffic from the server would have a source port of TCP22 and a destination port of TCP47846. A stateful firewall is able to track this, and hence rules are not required to be defined in both directions.

Playbook

The contents of the playbook are below: -

---
## tasks file for firewall
- name: Remove default firewall config
  junos_config:
    lines:
      - delete security zones security-zone trust
      - delete security zones security-zone untrust
      - delete security screen ids-option untrust-screen
      - delete security policies from-zone trust to-zone trust policy default-permit
      - delete security policies from-zone trust to-zone untrust policy default-permit
  tags:
    - firewall

- name: Define Firewall Zones
  junos_config:
    src: zones.j2
  when:
    - zones is defined
  tags:
    - firewall

- name: Add interfaces to zones
  junos_config:
    src: int_zones.j2
  tags:
    - firewall

- name: Define Addresses
  junos_config:
    src: addressbook.j2
  when:
    - addressbook is defined
  tags:
    - firewall

- name: Define Applications
  junos_config:
    src: apps.j2
  when:
    - apps is defined
  tags:
    - firewall

- name: Define Global Policies
  junos_config:
    src: globalpolicy.j2
    update: replace
  when:
    - fw_policies is defined
  tags:
    - firewall

- name: Define Zone Policies
  junos_config:
    src: policy.j2
    update: replace
  when:
    - fw_policies is defined
  tags:
    - firewall

JunOS uses the following terms when it comes to zone-based firewalling: -

  • Zones - A logical grouping of one or multiple interfaces (e.g. internal, edge, finance)
  • Address books - The IP addresses (v4 or v6, or can be DNS-based) of the hosts/ranges to firewall
  • Applications - The applications (e.g. SSH, SIP, IPSec) that will be matched
  • Policies - The policies that are applied for traffic traversing between, or inside a zone

Firewall policies are applied at the zone-level, allowing multiple interfaces to be covered by the same policies. This allows you to group all of your internal-facing interfaces into one logical zone, or all your external peering/transit interfaces into one logical zone. This makes applying firewalling to multiple interfaces much easier.

Also, JunOS has the ability to apply Global policies. These match any traffic traversing the firewall, no matter the source or destination zone. This can be useful for traffic that will always be allowed (for example, always allowing ICMP).

All tasks use the junos_config module, as no Ansible module currently exists for SRX firewalling.

Removing default firewall configuration

Ansible module: junos_config

JunOS SRXs have a few zones and features configured and enabled by default. These include a trust zone, an untrust zone, some default firewall policies (allow any interface in the trust zone to speak to any interface in either the trust or untrust zone) and an IDS (Intrustion Detection System) enabled.

This task removes all of the defaults so that we can define our own zones and policies.

Defining Zones

Ansible module: junos_config

This task defines the firewall zones, and places interfaces into them. The host_vars used as part of this are: -

zones:
  - name: "edge"
    host_traffic:
      protocols:
        - bgp
      services:
        - ping
        - traceroute
  - name: "internet"
    nat: 
      role: "outside"
    host_traffic:
      services:
        - ping
        - traceroute
        - dhcp
  - name: "internal"
    nat:
      role: "inside"
    host_traffic:
      protocols:
        - bgp
        - ospf
        - ospf3
      services:
        - ping
        - traceroute

Our template looks like the below: -

{% for zone in zones %}
set security zones security-zone {{ zone['name'] }}
set security address-book {{ zone['name'] }} attach zone {{ zone['name'] }}
{% if zone['host_traffic'] is defined %}
{% if zone['host_traffic']['protocols'] is defined %}
{% for protocol in zone['host_traffic']['protocols'] %}
set security zones security-zone {{ zone['name'] }} host-inbound-traffic protocols {{ protocol }} 
{% endfor %}
{% endif %}
{% if zone['host_traffic']['services'] is defined %}
{% for service in zone['host_traffic']['services'] %}
set security zones security-zone {{ zone['name'] }} host-inbound-traffic system-services {{ service }} 
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}

First we loop through the zones. For each zone that is defined, we create it, and also create an associated address-book (i.e. the list of hosts we’ll be firewalling).

After that, we configure host-inbound-traffic. This is any traffic that destined for firewall itself (rather than traversing through it). It could be routing protocol traffic, pings or DHCP. Without this, BGP sessions will not form, OSPF will not work and we will not get any DHCP leases.

The two sections deal with protocols and system-services. For routing protocols, or VRRP (Virtual Router Redundancy Protocol) for highly-available virtual IPs, these would go into the protocols section. For anything like pings, DHCP, SSH, VPNs, these go in system-services.

You can find the full list available on a JunOS device by typing in set security-zones security zone test host-inbound-traffic protocols ? or set security-zones security zone test host-inbound-traffic system-services ? from configuration mode.

The generated configuration, based upon our host_vars, is below: -

set security zones security-zone edge host-inbound-traffic system-services ping
set security zones security-zone edge host-inbound-traffic system-services traceroute
set security zones security-zone edge host-inbound-traffic system-services dhcp
set security zones security-zone edge host-inbound-traffic protocols bgp
set security zones security-zone internal host-inbound-traffic system-services ping
set security zones security-zone internal host-inbound-traffic system-services traceroute
set security zones security-zone internal host-inbound-traffic protocols bgp
set security zones security-zone internal host-inbound-traffic protocols ospf
set security zones security-zone internal host-inbound-traffic protocols ospf3
set security zones security-zone internet host-inbound-traffic system-services ping
set security zones security-zone internet host-inbound-traffic system-services traceroute
set security zones security-zone internet host-inbound-traffic system-services dhcp
Adding interfaces to zones

Ansible module: junos_config

This task adds the interfaces to the relevant zones. The host_vars used for this are: -

interfaces:
  - junos_if: "fxp0"
    unit: 0
  - junos_if: "ge-0/0/0"
    unit: 0
  - junos_if: "ge-0/0/0"
    unit: 102
    if_zone: "edge"
  - junos_if: "ge-0/0/0"
    unit: 202
    if_zone: "internal"
  - junos_if: "ge-0/0/1"
    unit: 0
    if_zone: "internet"
  - junos_if: "lo0"
    unit: 0
    if_zone: "internal"

The template looks like the below: -

{% for interface in interfaces %}
{% if interface['if_zone'] is defined %}
set security zones security-zone {{ interface['if_zone'] }} interfaces {{ interface['junos_if'] }}.{{ interface['unit'] }}
{% endif %}
{% endfor %}

The template loops through our interfaces, and if a zone is defined, it configures the interface as part of the zone. Notice in the above host_vars that we do not add the fxp0 interface (our management interface) or the underlying ge-0/0/0 interface (which carries VLANs) as part of a zone.

The generated configuration looks like the below: -

set security zones security-zone edge interfaces ge-0/0/0.102
set security zones security-zone internal interfaces ge-0/0/0.202
set security zones security-zone internal interfaces lo0.0
set security zones security-zone internet interfaces ge-0/0/1.0
Define addresses

Ansible modules: junos_config

This task defines all the hosts/ranges that will be firewalled. It can either apply them to the global address book (and can therefore be used in any zone), or to a zone-specific address book, at which point they can only be used for rules/policies referencing that zone.

The separation may seem strange, but it makes it cleaner and easier to find what hosts belong to what zone in configuration, rather than just one big set of hosts.

The template used is below: -

{% for address in addressbook %}
{% if 'global' in address['zone'] %}
set security address-book global address {{ address['name'] }} {{ address['ip'] }}
{% if address['set'] is defined %}
set security address-book global address-set {{ address['set'] }} address {{ address['name'] }} 
{% endif %}
{% else %}
set security address-book {{ address['zone'] }} address {{ address['name'] }} {{ address['ip'] }}
{% if address['set'] is defined %}
{% for addr_set in address['set'] %}
set security address-book {{ address['zone'] }} address-set {{ addr_set }} address {{ address['name'] }}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}

In the above, we define addresses, and also something called an address-set. This is a group of addresses that should be firewalled the same (say, multiple web servers, or multiple user groups). An address can also be part of multiple sets.

Our host_vars for this are below: -

addressbook:
  - zone: edge
    name: netsvr
    ip: 10.100.102.254
    set:
      - external_bgp_peers
      - netsvr-direct
  - zone: edge
    name: netsvr-v6
    ip: "2001:db8:102::ffff/128"
    set:
      - external_bgp_peers
      - netsvr-direct
  - zone: edge
    name: netsvr-lo
    ip: 192.0.2.1
    set:
    - netsvr-loop
  - zone: edge
    name: netsvr-lo-v6
    ip: "2001:db8:999:beef::1/128"
    set: 
    - netsvr-loop
  - zone: internal
    name: internal-rtr
    ip: 192.0.2.202
    set: 
    - internal_bgp_peers
    - internal-rtr-loop
  - zone: internal
    name: internal-rtr-v6
    ip: "2001:db8:902:beef::2/128"
    set: 
    - internal_bgp_peers
    - internal-rtr-loop

This would then generate the following configuration: -

set security address-book edge address netsvr 10.100.102.254/32
set security address-book edge address netsvr-v6 2001:db8:102::ffff/128
set security address-book edge address netsvr-lo 192.0.2.1/32
set security address-book edge address netsvr-lo-v6 2001:db8:999:beef::1/128
set security address-book edge address-set external_bgp_peers address netsvr
set security address-book edge address-set external_bgp_peers address netsvr-v6
set security address-book edge address-set netsvr-direct address netsvr
set security address-book edge address-set netsvr-direct address netsvr-v6
set security address-book edge address-set netsvr-loop address netsvr-lo
set security address-book edge address-set netsvr-loop address netsvr-lo-v6
set security address-book internal address internal-rtr 192.0.2.202/32
set security address-book internal address internal-rtr-v6 2001:db8:902:beef::2/128
set security address-book internal address-set internal_bgp_peers address internal-rtr
set security address-book internal address-set internal_bgp_peers address internal-rtr-v6
set security address-book internal address-set internal-rtr-loop address internal-rtr
set security address-book internal address-set internal-rtr-loop address internal-rtr-v6

As you can see, some of the addresses are in multiple address-sets. With this, we can apply firewall policies that cover all of our external BGP peers, or all of our internal BGP peers.

Defining applications

Ansible module: junos_config

In JunOS, applications are the ports and/or protocols that will be matched by a firewall policy.

Many applications are already defined by default, so you may not need to add any yourself. In fact in this lab, I have used “well-known” (i.e. common) applications, so this task will actually not configure anything during this lab.

The template looks like the below: -

{% for app in apps %}
set applications application {{ app['name'] }} destination-port {{ app['port'] }}
set applications application {{ app['name'] }} protocol {{ app['proto'] }}
{% endfor %} 

This loops through a list of apps in our host_vars. For each in the list, we configure the name, port and protocol.

Global Policies

Ansible module: junos_config

This task configures global policies. The policies tie together the source and/or destination of traffic, the applications to match, and the action (i.e. permit or deny). These are applied globally (i.e. across all zones, rather than being zone specific).

The template looks like the below: -

{% for policy in fw_policies %}
{% if 'global' in policy['zone'] %}
delete security policies global policy {{ policy['name'] }}
{% if 'any' in policy['source'] %}
set security policies global policy {{ policy['name'] }}  match source-address any
{% else %}
{% for s_addr in policy['source'] %}
set security policies global policy {{ policy['name'] }}  match source-address {{ s_addr }}
{% endfor %}
{% endif %}
{% if 'any' in policy['destination'] %}
set security policies global policy {{ policy['name'] }}  match destination-address any
{% else %}
{% for d_addr in policy['destination'] %}
set security policies global policy {{ policy['name'] }}  match destination-address {{ d_addr }}
{% endfor %}
{% endif %}
{% if 'any' in policy['apps'] %}
set security policies global policy {{ policy['name'] }} match application any
{% else %}
{% for app in policy['apps'] %}
set security policies global policy {{ policy['name'] }} match application {{ app }}
{% endfor %}
{% endif %}
set security policies global policy {{ policy['name'] }} then {{ policy['action'] }}
{% endif %}
{% endfor %}

First, we go through our fw_policies list (defined in our host_vars) and then match any policy which has a zone of global. We then delete the policy, to remove any existing configuration. This stops us from adding to existing policies, potentially allowing hosts through that no longer exist or that no longer should have access. Each policy is rebuilt.

After that, we check to see the source addresses defined. If the source address is any, then any source is matched by this policy. Otherwise, we loop through a list of addresses (or address-sets, both are applicable) and define them as part of the policy. This matches where the traffic originates from.

We then do the same, but for the destination addresses. This matches where the traffic is destined for.

Next we match applications. They can be any application (i.e. any destination port/protocol), or based upon the list of applications in our host_vars.

Finally, we then say whether the policy accepts or rejects the traffic (the action).

In our host_vars, we have the following policy that is applied at the global level: -

fw_policies:
  - name: all_icmp
    zone: global
    source: any
    destination: any
    action: permit
    apps:
      - junos-ping
      - junos-pingv6

This generates the following configuration: -

set security policies global policy all_icmp match source-address any
set security policies global policy all_icmp match destination-address any
set security policies global policy all_icmp match application junos-ping
set security policies global policy all_icmp match application junos-pingv6
set security policies global policy all_icmp then permit
Define zone policies

Ansible module: junos_config

This task is very similar to the previous task, except they are zone-specific (rather than applied across all zones). By default, traffic will not be allowed between zones unless a policy is defined to allow it, nor will traffic be allowed between interfaces in the same zone.

The latter may seem odd, but you may want to apply a common policy of what users are allowed to access of your company’s resources without necessarily allowing them to access each other’s workstations.

The template for this looks like the below: -

{% for policy in fw_policies %}
{% if 'global' not in policy['zone'] %}
delete security policies from-zone {{ policy['from_zone'] }} to-zone {{ policy['to_zone'] }} policy {{ policy['name'] }}
{% if 'any' in policy['source'] %}
set security policies from-zone {{ policy['from_zone'] }} to-zone {{ policy['to_zone'] }} policy {{ policy['name'] }}  match source-address any
{% else %}
{% for s_addr in policy['source'] %}
set security policies from-zone {{ policy['from_zone'] }} to-zone {{ policy['to_zone'] }} policy {{ policy['name'] }}  match source-address {{ s_addr }}
{% endfor %}
{% endif %}
{% if 'any' in policy['destination'] %}
set security policies from-zone {{ policy['from_zone'] }} to-zone {{ policy['to_zone'] }} policy {{ policy['name'] }}  match destination-address any
{% else %}
{% for d_addr in policy['destination'] %}
set security policies from-zone {{ policy['from_zone'] }} to-zone {{ policy['to_zone'] }} policy {{ policy['name'] }}  match destination-address {{ d_addr }}
{% endfor %}
{% endif %}
{% if 'any' in policy['apps'] %}
set security policies from-zone {{ policy['from_zone'] }} to-zone {{ policy['to_zone'] }} policy {{ policy['name'] }} match application any 
{% else %}
{% for app in policy['apps'] %}
set security policies from-zone {{ policy['from_zone'] }} to-zone {{ policy['to_zone'] }} policy {{ policy['name'] }} match application {{ app }}
{% endfor %}
{% endif %}
set security policies from-zone {{ policy['from_zone'] }} to-zone {{ policy['to_zone'] }} policy {{ policy['name'] }} then {{ policy['action'] }}
{% endif %}
{% endfor %}

The above is effectively an extended version of the global policy task. We now also include the from_zone and to_zone variables. As before, we also delete the policy at the start, so that we are not just updating an existing policy (and potentially leaving old hosts/applications in the policy), and rebuild it.

The host_vars that are referenced by this task are below: -

fw_policies:
  - name: a_tacacs
    zone: internal
    from_zone: internal
    to_zone: edge
    source: any
    destination: 
      - netsvr-loop
    action: permit
    apps:
      - junos-tacacs
  - name: a_syslog
    zone: internal
    from_zone: internal
    to_zone: edge
    source: any
    destination: 
      - netsvr-direct
    action: permit
    apps:
      - junos-syslog
  - name: a_ext_bgp
    zone: edge
    from_zone: edge
    to_zone: edge
    source: 
      - external_bgp_peers
    destination: any
    action: permit
    apps:
      - junos-bgp
  - name: a_ext_bgp_host
    zone: edge
    from_zone: edge
    to_zone: junos-host
    source: 
      - external_bgp_peers
    destination: any
    action: permit
    apps:
      - junos-bgp
  - name: a_int_bgp
    zone: internal
    from_zone: internal
    to_zone: internal
    source: 
      - internal_bgp_peers
    destination: any
    action: permit
    apps:
      - junos-bgp
  - name: a_int_bgp_host
    zone: internal
    from_zone: internal
    to_zone: junos-host
    source: 
      - internal_bgp_peers
    destination: any
    action: permit
    apps:
      - junos-bgp
  - name: a_internet_routing
    zone: internal
    from_zone: internal
    to_zone: internal
    source: any
    destination: any
    action: permit
    apps: any

This then builds the following firewall policies: -

set security policies from-zone internal to-zone edge policy a_tacacs match source-address any
set security policies from-zone internal to-zone edge policy a_tacacs match destination-address netsvr-loop
set security policies from-zone internal to-zone edge policy a_tacacs match application junos-tacacs
set security policies from-zone internal to-zone edge policy a_tacacs then permit
set security policies from-zone internal to-zone edge policy a_syslog match source-address any
set security policies from-zone internal to-zone edge policy a_syslog match destination-address netsvr-direct
set security policies from-zone internal to-zone edge policy a_syslog match application junos-syslog
set security policies from-zone internal to-zone edge policy a_syslog then permit
set security policies from-zone edge to-zone edge policy a_ext_bgp match source-address external_bgp_peers
set security policies from-zone edge to-zone edge policy a_ext_bgp match destination-address any
set security policies from-zone edge to-zone edge policy a_ext_bgp match application junos-bgp
set security policies from-zone edge to-zone edge policy a_ext_bgp then permit
set security policies from-zone edge to-zone junos-host policy a_ext_bgp_host match source-address external_bgp_peers
set security policies from-zone edge to-zone junos-host policy a_ext_bgp_host match destination-address any
set security policies from-zone edge to-zone junos-host policy a_ext_bgp_host match application junos-bgp
set security policies from-zone edge to-zone junos-host policy a_ext_bgp_host then permit
set security policies from-zone internal to-zone internal policy a_int_bgp match source-address internal_bgp_peers
set security policies from-zone internal to-zone internal policy a_int_bgp match destination-address any
set security policies from-zone internal to-zone internal policy a_int_bgp match application junos-bgp
set security policies from-zone internal to-zone internal policy a_int_bgp then permit
set security policies from-zone internal to-zone internal policy a_internet_routing match source-address any
set security policies from-zone internal to-zone internal policy a_internet_routing match destination-address any
set security policies from-zone internal to-zone internal policy a_internet_routing match application any
set security policies from-zone internal to-zone internal policy a_internet_routing then permit
set security policies from-zone internal to-zone junos-host policy a_int_bgp_host match source-address internal_bgp_peers
set security policies from-zone internal to-zone junos-host policy a_int_bgp_host match destination-address any
set security policies from-zone internal to-zone junos-host policy a_int_bgp_host match application junos-bgp
set security policies from-zone internal to-zone junos-host policy a_int_bgp_host then permit

Outcomes

While our host_vars are quite lengthy, we still save significantly on the amount of configuration we have to define.

To compare, the total JunOS configuration contains 538 different words/elements, whereas our host_vars contain 210 different words/elements. This will grow exponentially as more rules are added.

Verification

Now we can verify whether the access lists are working: -

! Show the hit count on the policies
ansible@junos-01> show security policies hit-count    
Logical system: root-logical-system
 Index   From zone        To zone           Name           Policy count
 1       junos-global     junos-global      all_icmp       0            
 2       edge             junos-host        a_ext_bgp_host 0            
 3       edge             edge              a_ext_bgp      0            
 4       internal         junos-host        a_int_bgp_host 1            
 5       internal         edge              a_tacacs       2            
 6       internal         edge              a_syslog       1            
 7       internal         internal          a_int_bgp      1            
 8       internal         internal          a_internet_routing 0            

! What happens if we try to SSH from the netsvr?

$ ssh 10.100.102.253
^C

! Can we still ping it?
$ ping 10.100.102.253
PING 10.100.102.253 (10.100.102.253) 56(84) bytes of data.
64 bytes from 10.100.102.253: icmp_seq=1 ttl=64 time=12.1 ms
64 bytes from 10.100.102.253: icmp_seq=2 ttl=64 time=0.364 ms
^C
--- 10.100.102.253 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 2ms
rtt min/avg/max/mdev = 0.364/6.233/12.103/5.870 ms

Looks like it works!

Routing

For routing, we use BGP, OSPF and OSPFv3. OSPF is used for internal IPv4 networks, OSPFv3 for internal IPv6 networks, and BGP for external routing.

Main Playbook

The main playbook looks like the below: -

---
## tasks file for routing
##
- name: Include OSPF routing
  include: ospf.yml

- name: Include OSPFv3 routing
  include: ospfv3.yml

- name: Include BGP routing
  include: bgp.yml

As before, we are separating out the BGP, OSPF and OSPFv3 playbooks. This allows us to separate out all the tasks, rather than having them in one big playbook.

OSPF Playbook

The OSPF playbook itself looks like: -

---
## tasks file for routing
##
- name: OSPF Process - Router ID
  junos_config:
    lines: 
      - "set routing-options router-id {{ router_id }}"
  tags:
    - ospf
    - ospf_v4

- name: OSPF Interfaces
  junos_config:
    lines:
      - set protocols ospf area {{ item.ospf.area }} interface {{ item.junos_if }}.{{ item.unit }}
  when: item.ospf is defined
  loop: "{{ interfaces }}"
  tags:
    - ospf
    - ospf_v4

- name: OSPF Interfaces - Passive
  junos_config:
    lines:
      - set protocols ospf area {{ item.ospf.area }} interface {{ item.junos_if }}.{{ item.unit }} passive 
  when: 
    - item.ospf is defined
    - item.ospf.passive is defined
  loop: "{{ interfaces }}"
  tags:
    - ospf
    - ospf_v4

If you compare this to the Cisco IOS playbook, you’ll notice that we aren’t using the parents option for the junos_config modules. This is because within JunOS, all commands are available from the base hierarchy, rather than needing to be within certain hierarchical levels to apply certain commands.

While the commands themselves look more verbose, the tasks themselves require far fewer options.

As with IOS, there are no OSPF modules for JunOS, so we will be using junos_config again.

Router ID

Ansible module: junos_config

This task sets the router ID. One point to note here is that the router ID is global, so this applies for OSPF, OSPFv3, BGP, IS-IS and anything else that uses a router ID.

We source the ID from our host_vars: -

router_id: 192.0.2.201

This generates the following configuration: -

set routing-options router-id 192.0.2.201
OSPF Interfaces

Ansible module: junos_config

This task goes through our list of interfaces, and if the ospf field is defined, it enables OSPF for that interface.

This is defined in our host_vars: -

interfaces:
  - junos_if: "ge-0/0/0"
    unit: 102
    desc: "To netsvr"
    enabled: "true"
    if_zone: "edge"
    ipv4_addr: "10.100.102.253/24"
    ipv6_addr: "2001:db8:102::f/64"
    ospf:
      area: "0.0.0.0"
  - junos_if: "ge-0/0/0"
    unit: 202
    desc: "To junos-02"
    enabled: "true"
    ipv4_addr: "10.100.202.254/24"
    ipv6_addr: "2001:db8:202::a/64"
    if_zone: "internal"
    ospf:
      area: "0.0.0.0"
  - junos_if: "ge-0/0/1"
    desc: "To the Internet"
    unit: 0
    enabled: "true"
    ipv4_addr: "dhcp"
    if_zone: "internet"
    ospf:
      area: "0.0.0.0"
  - junos_if: "lo0"
    unit: 0
    desc: "Loopback"
    enabled: "true"
    ipv4_addr: "192.0.2.102/32"
    ipv6_addr: "2001:db8:902:beef::1/128"
    ospf:
      area: "0.0.0.0"

Based upon the above, we would generate: -

set protocols ospf area 0.0.0.0 interface ge-0/0/0.102
set protocols ospf area 0.0.0.0 interface ge-0/0/0.202
set protocols ospf area 0.0.0.0 interface ge-0/0/1.0
set protocols ospf area 0.0.0.0 interface lo0.0 
OSPF Interfaces - Passive

Ansible modules: junos_config

This is similar to the above, except for any interface with the passive field under OSPF, we also generate the passive configuration.

The host_vars relevant to this are: -

interfaces:
  - junos_if: "ge-0/0/0"
    unit: 102
    desc: "To netsvr"
    enabled: "true"
    if_zone: "edge"
    ipv4_addr: "10.100.102.253/24"
    ipv6_addr: "2001:db8:102::f/64"
    ospf:
      area: "0.0.0.0"
      passive: true
  - junos_if: "ge-0/0/1"
    desc: "To the Internet"
    unit: 0
    enabled: "true"
    ipv4_addr: "dhcp"
    if_zone: "internet"
    ospf:
      area: "0.0.0.0"
      passive: true
  - junos_if: "lo0"
    unit: 0
    desc: "Loopback"
    enabled: "true"
    ipv4_addr: "192.0.2.102/32"
    ipv6_addr: "2001:db8:902:beef::1/128"
    ospf:
      area: "0.0.0.0"
      passive: true

This would then generate the following configuration: -

set protocols ospf area 0.0.0.0 interface ge-0/0/0.102 passive
set protocols ospf area 0.0.0.0 interface ge-0/0/1.0 passive
set protocols ospf area 0.0.0.0 interface lo0.0 passive
Verification

After this, we should be able to see OSPF routes on both the edge router and the internal router: -

junos-01

! Show OSPF interfaces
ansible@junos-01> show ospf interface 
Interface           State   Area            DR ID           BDR ID          Nbrs
ge-0/0/0.102        DRother 0.0.0.0         0.0.0.0         0.0.0.0            0
ge-0/0/0.202        DR      0.0.0.0         192.0.2.102     192.0.2.202        1
ge-0/0/1.0          DRother 0.0.0.0         0.0.0.0         0.0.0.0            0
lo0.0               DRother 0.0.0.0         0.0.0.0         0.0.0.0            0

! Show OSPF neighbours
ansible@junos-01> show ospf neighbor     
Address          Interface              State     ID               Pri  Dead
10.100.202.253   ge-0/0/0.202           Full      192.0.2.202      128    36

! Show routing table
ansible@junos-01> show route protocol ospf   

inet.0: 13 destinations, 13 routes (13 active, 0 holddown, 0 hidden)
+ = Active Route, - = Last Active, * = Both

192.0.2.202/32     *[OSPF/10] 00:29:36, metric 1
                    >  to 10.100.202.253 via ge-0/0/0.202
224.0.0.5/32       *[OSPF/10] 00:33:11, metric 1
                       MultiRecv

! Can we ping?
ansible@junos-01> ping 192.0.2.202    
PING 192.0.2.202 (192.0.2.202): 56 data bytes
64 bytes from 192.0.2.202: icmp_seq=0 ttl=64 time=10.032 ms
64 bytes from 192.0.2.202: icmp_seq=1 ttl=64 time=0.562 ms
^C
--- 192.0.2.202 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.562/5.297/10.032/4.735 ms

Why do we have a route for 224.0.0.5/32? OSPF uses Multicast to find OSPF neighbours on Ethernet networks, and the reserved multicast address for OSPF is 224.0.0.5/32. Technically it is not an advertised route, but it is still a route specific to OSPF.

junos-02

! Show OSPF interfaces
yetiops@junos-02> show ospf interface 
Interface           State   Area            DR ID           BDR ID          Nbrs
ge-0/0/0.202        BDR     0.0.0.0         192.0.2.102     192.0.2.202        1
lo0.0               DRother 0.0.0.0         0.0.0.0         0.0.0.0            0

! Show OSPF neighbours
yetiops@junos-02> show ospf neighbor 
Address          Interface              State     ID               Pri  Dead
10.100.202.254   ge-0/0/0.202           Full      192.0.2.102      128    35

! Show routing table
yetiops@junos-02> show route protocol ospf   

inet.0: 11 destinations, 17 routes (11 active, 0 holddown, 3 hidden)
+ = Active Route, - = Last Active, * = Both

10.100.102.0/24    *[OSPF/10] 00:33:15, metric 2
                    > to 10.100.202.254 via ge-0/0/0.202
192.0.2.102/32     *[OSPF/10] 00:33:15, metric 1
                    > to 10.100.202.254 via ge-0/0/0.202
192.168.122.0/24   *[OSPF/10] 00:33:15, metric 2
                    > to 10.100.202.254 via ge-0/0/0.202
224.0.0.5/32       *[OSPF/10] 00:35:11, metric 1
                      MultiRecv

! Can we ping?
yetiops@junos-02> ping 192.0.2.102 
PING 192.0.2.102 (192.0.2.102): 56 data bytes
64 bytes from 192.0.2.102: icmp_seq=0 ttl=64 time=22.991 ms
64 bytes from 192.0.2.102: icmp_seq=1 ttl=64 time=44.280 ms
^C
--- 192.0.2.102 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/stddev = 22.991/33.636/44.280/10.645 ms

yetiops@junos-02> ping 10.100.102.253 
PING 10.100.102.253 (10.100.102.253): 56 data bytes
64 bytes from 10.100.102.253: icmp_seq=0 ttl=64 time=23.840 ms
64 bytes from 10.100.102.253: icmp_seq=1 ttl=64 time=1.241 ms
^C
--- 10.100.102.253 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/stddev = 1.241/12.540/23.840/11.300 ms

It’s all looking good!

OSPFv3 Playbook

As noted, we are using OSPFv3 for IPv6 internal routing. We could also use it for IPv4, but not every vendors supports this option. We will use it for IPv6 only for now (for interoperability reasons).

The playbook for OSPFv3 has fewer tasks that for OSPF, as we are not settings the router ID as part of it.

---
## tasks file for routing
##

- name: OSPFv3 Interfaces
  junos_config:
    lines:
      - set protocols ospf3 area {{ item.ospf.area }} interface {{ item.junos_if }}.{{ item.unit }} 
  when: item.ospfv3 is defined
  loop: "{{ interfaces }}"
  tags:
    - ospf
    - ospf_v6

- name: OSPFv3 Interfaces - Passive
  junos_config:
    lines:
      - set protocols ospf3 area {{ item.ospf.area }} interface {{ item.junos_if }}.{{ item.unit }} passive
  when: 
    - item.ospfv3 is defined
    - item.ospfv3.passive is defined
  loop: "{{ interfaces }}"
  tags:
    - ospf
    - ospf_v6

The tasks are fundamentally the same as the tasks for enabling OSPF. The only major difference is that we use ospf3 instead of ospf in the commands.

The host_vars we used are: -

interfaces:
  - junos_if: "ge-0/0/0"
    unit: 102
    desc: "To netsvr"
    enabled: "true"
    if_zone: "edge"
    ipv4_addr: "10.100.102.253/24"
    ipv6_addr: "2001:db8:102::f/64"
    ospf:
      area: "0.0.0.0"
      passive: true
    ospfv3:
      area: "0.0.0.0"
      passive: true
  - junos_if: "ge-0/0/0"
    unit: 202
    desc: "To junos-02"
    enabled: "true"
    ipv4_addr: "10.100.202.254/24"
    ipv6_addr: "2001:db8:202::a/64"
    if_zone: "internal"
    ospf:
      area: "0.0.0.0"
    ospfv3:
      area: "0.0.0.0"
  - junos_if: "lo0"
    unit: 0
    desc: "Loopback"
    enabled: "true"
    if_zone: "internal"
    ipv4_addr: "192.0.2.102/32"
    ipv6_addr: "2001:db8:902:beef::1/128"
    ospf:
      area: "0.0.0.0"
      passive: true
    ospfv3:
      area: "0.0.0.0"
      passive: true

The following configuration is generated: -

set protocols ospf3 area 0.0.0.0 interface ge-0/0/0.102 passive
set protocols ospf3 area 0.0.0.0 interface ge-0/0/0.202
set protocols ospf3 area 0.0.0.0 interface lo0.0 passive
Verification

We’ll follow the same steps as we did for OSPF: -

junos-01

! Show OSPFv3 interfaces
ansible@junos-01> show ospf3 interface   
Interface           State   Area            DR ID           BDR ID          Nbrs
ge-0/0/0.102        DR      0.0.0.0         192.0.2.102     0.0.0.0            0
ge-0/0/0.202        DR      0.0.0.0         192.0.2.102     0.0.0.0            0
lo0.0               DR      0.0.0.0         192.0.2.102     0.0.0.0            0

! Show OSPFv3 neighbours
ansible@junos-01> show ospf3 neighbor     
ID               Interface              State     Pri   Dead
192.0.2.202      ge-0/0/0.202           Full      128     38
  Neighbor-address fe80::5254:0:cac2:2ecb

! Show routing table
ansible@junos-01> show route protocol ospf3 

inet.0: 13 destinations, 13 routes (13 active, 0 holddown, 0 hidden)

inet6.0: 12 destinations, 14 routes (12 active, 0 holddown, 0 hidden)
+ = Active Route, - = Last Active, * = Both

2001:db8:902:beef::2/128
                   *[OSPF3/10] 00:01:31, metric 1
                    >  to fe80::5254:0:cac2:2ecb via ge-0/0/0.202
ff02::5/128        *[OSPF3/10] 00:05:08, metric 1
                       MultiRecv

! Ping!
ansible@junos-01> ping 2001:db8:902:beef::2                                
PING6(56=40+8+8 bytes) 2001:db8:202::a --> 2001:db8:902:beef::2
16 bytes from 2001:db8:902:beef::2, icmp_seq=0 hlim=64 time=23.113 ms
16 bytes from 2001:db8:902:beef::2, icmp_seq=1 hlim=64 time=0.901 ms
^C
--- 2001:db8:902:beef::2 ping6 statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/std-dev = 0.901/12.007/23.113/11.106 ms

As in OSPF, we also have a multicast address for OSPFv3. In this case, it is ff02::5/128. Again, this is not advertised by OSPFv3 but is specific to the protocol.

junos-02

! Show OSPFv3 interfaces
ansible@junos-02> show ospf3 interface 
Interface           State   Area            DR ID           BDR ID          Nbrs
ge-0/0/0.202        DR      0.0.0.0         192.0.2.202     192.0.2.102        1
lo0.0               DRother 0.0.0.0         0.0.0.0         0.0.0.0            0

! Show OSPFv3 neighbours
ansible@junos-02> show ospf3 neighbor 
ID               Interface              State     Pri   Dead
192.0.2.102      ge-0/0/0.202           Full      128     32
  Neighbor-address fe80::5254:0:cafe:ea97

! Show routing table
ansible@junos-02> show route protocol ospf3 

inet.0: 11 destinations, 17 routes (11 active, 0 holddown, 3 hidden)

inet6.0: 10 destinations, 14 routes (10 active, 0 holddown, 2 hidden)
+ = Active Route, - = Last Active, * = Both

2001:db8:102::/64  *[OSPF3/10] 00:07:03, metric 2
                    >  to fe80::5254:0:cafe:ea97 via ge-0/0/0.202
2001:db8:902:beef::1/128
                   *[OSPF3/10] 00:07:03, metric 1
                    >  to fe80::5254:0:cafe:ea97 via ge-0/0/0.202
ff02::5/128        *[OSPF3/10] 00:13:01, metric 1
                       MultiRecv

! Ping!
ansible@junos-02> ping 2001:db8:902:beef::1    
PING6(56=40+8+8 bytes) 2001:db8:202::f --> 2001:db8:902:beef::1
16 bytes from 2001:db8:902:beef::1, icmp_seq=0 hlim=64 time=0.585 ms
16 bytes from 2001:db8:902:beef::1, icmp_seq=1 hlim=64 time=17.187 ms
^C
--- 2001:db8:902:beef::1 ping6 statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/std-dev = 0.585/8.886/17.187/8.301 ms

ansible@junos-02> ping 2001:db8:102::f       
PING6(56=40+8+8 bytes) 2001:db8:202::f --> 2001:db8:102::f
16 bytes from 2001:db8:102::f, icmp_seq=0 hlim=64 time=12.074 ms
16 bytes from 2001:db8:102::f, icmp_seq=1 hlim=64 time=11.933 ms
16 bytes from 2001:db8:102::f, icmp_seq=2 hlim=64 time=1.510 ms
^C
--- 2001:db8:102::f ping6 statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max/std-dev = 1.510/8.506/12.074/4.947 ms

BGP Playbook

The BGP playbook is where we configure our internal and external BGP peers. Unlike Cisco IOS, there are no JunOS Ansible modules for BGP. Instead, we must use the junos_config module.

We are also applying prefix lists and route policies to only allow certain routes to be passed. In day to day BGP configuration, you would configure these all the time, so it makes sense to include tasks to configure them.

The playbook itself looks like the below: -

---
- name: Configure Prefix Lists 
  junos_config:
    src: prefixlists.j2
  when:
    - route_policies is defined
    - route_policies.prefix_lists is defined
  tags:
    - bgp
    - bgp_v4
    - bgp_v6

- name: Configure Route Policies 
  junos_config:
    src: policies.j2
  when:
    - route_policies is defined
    - route_policies.rp is defined
  tags:
    - bgp
    - bgp_v4
    - bgp_v6

- name: Configure BGP Peers
  junos_config:
    src: bgp_group.j2
  when:
    - bgp is defined
  tags:
    - bgp
    - bgp_v4
    - bgp_v6

What is interesting here is that we do not need to separate our IPv4 and IPv6 configuration. Rather than requiring configuration in different address families (i.e. Cisco IOS style), JunOS allows you to configure IPv4 and IPv6 within the same group and same hierarchical level.

Prefix Lists

Ansible module: junos_config

Prefix lists are used to match a range of IP addresses. They can be exact matches (i.e. 192.168.0.0/24), or you can match on a longer or shorter prefix length.

As an example, you can say that if the routes received have a subnet mask of a /24 or longer (i.e. /25, /26, /27 etc), then accept them. You would tend to use something like this when accepting routes from internal routers, or customer connections.

If you are configuring transit (i.e. receiving the full Internet routing table from a provider) or peering (e.g. an Internet Exchange like LINX or AMS-IX) connections, then you would likely only advertise routes that are /24 or shorter (i.e. /24, /23, /22 etc). This is because most transit providers do not accept more specific routes than this, to ensure that the global routing table is still a manageable size. If transit providers accepted everything down to /32 routes, the internet routing table would be orders of magnitude larger than it is. It is around 800,000 routes (at the time of writing this, which is already too large for some older (yet still widely used) routers.

The configuration template looks like the below: -

{% for prefix_list in route_policies['prefix_lists'] %}
delete policy-options prefix-list {{ prefix_list['name'] }}
{% for address in prefix_list['addresses'] %}
set policy-options prefix-list {{ prefix_list['name'] }} {{ address }}
{% endfor %}
set policy-options prefix-list {{ prefix_list['name'] }}_v4 apply-path "policy-options prefix-list {{ prefix_list['name'] }} <[0-9]*.[0-9]*.[0-9]*.[0-9]*>"
set policy-options prefix-list {{ prefix_list['name'] }}_v6 apply-path "policy-options prefix-list {{ prefix_list['name'] }} <*:*:*>"
{% endfor %}

First we delete the existing prefix-list. This allows us to rebuild it with the correct routes in it, rather than adding more and then manually removing old entries.

After that, we loop through our list of addresses, and add them to our prefix-list.

The next two lines create two dynamic prefix-lists, in that any IPv4 entries will create a prefix list of $NAME_v4, and any IPv6 entries will create a prefix list of $NAME_v6. Rather than needing to create a separate prefix list ourselves, we can make use of JunOS feature, in this case the apply-path command (and some regular expressions), to build them for us.

The prefixes are sourced from our host_vars: -

route_policies:
  prefix_lists:
    - name: internal_nets
      addresses:
         - 192.0.2.102/32
         - 192.0.2.202/32
         - 10.100.202.0/24
         - "2001:db8:902:beef::1/128"
         - "2001:db8:902:beef::2/128"
         - "2001:db8:902::/64"
    - name: external_nets
      addresses:
         - 192.0.2.1/32
         - "2001:db8:999:beef::1/128"
         - 10.100.102.0/24

The generated configuration would therefore look like the below: -

set policy-options prefix-list internal_nets_v4 apply-path "policy-options prefix-list internal_nets <[0-9]*.[0-9]*.[0-9]*.[0-9]*>"
set policy-options prefix-list internal_nets_v6 apply-path "policy-options prefix-list internal_nets <*:*:*>"
set policy-options prefix-list external_nets_v4 apply-path "policy-options prefix-list external_nets <[0-9]*.[0-9]*.[0-9]*.[0-9]*>"
set policy-options prefix-list external_nets_v6 apply-path "policy-options prefix-list external_nets <*:*:*>"
set policy-options prefix-list internal_nets 10.100.202.0/24
set policy-options prefix-list internal_nets 192.0.2.102/32
set policy-options prefix-list internal_nets 192.0.2.202/32
set policy-options prefix-list internal_nets 2001:db8:902::/64
set policy-options prefix-list internal_nets 2001:db8:902:beef::1/128
set policy-options prefix-list internal_nets 2001:db8:902:beef::2/128
set policy-options prefix-list external_nets 10.100.102.0/24
set policy-options prefix-list external_nets 192.0.2.1/32
set policy-options prefix-list external_nets 2001:db8:999:beef::1/128
Route Policies

Ansible module: junos_config

Route policies are used to apply our chosen actions to the routes advertised to or received from our BGP neighbours. They can also be used with other protocols too, but for our purposes we are using them specifically with BGP.

Our template looks like the below: -

{% for policy in route_policies['rp'] %}
delete policy-options policy-statement {{ policy['name'] }}
{% if policy['from']['protocols'] is defined and policy['from']['pfx_list'] is not defined %}
{% for protocol in policy['from']['protocols'] %}
set policy-options policy-statement {{ policy['name'] }} from protocol {{ protocol }}
set policy-options policy-statement {{ policy['name'] }} then {{ policy['action'] }}
{% endfor %}
{% endif %}
{% if policy['from']['pfx_list'] is defined %}
{% if policy['from']['protocols'] is defined %}
{% for protocol in policy['from']['protocols'] %}
set policy-options policy-statement {{ policy['name'] }} term v4 from protocol {{ protocol }}
{% endfor %}
{% endif %}
set policy-options policy-statement {{ policy['name'] }} term v4 from prefix-list {{ policy['from']['pfx_list'] }}_v4
set policy-options policy-statement {{ policy['name'] }} term v4 then {{ policy['action'] }}
{% if policy['from']['protocols'] is defined %}
{% for protocol in policy['from']['protocols'] %}
set policy-options policy-statement {{ policy['name'] }} term v6 from protocol {{ protocol }}
{% endfor %}
{% endif %}
set policy-options policy-statement {{ policy['name'] }} term v6 from prefix-list {{ policy['from']['pfx_list'] }}_v6
set policy-options policy-statement {{ policy['name'] }} term v6 then {{ policy['action'] }}
{% endif %}
{% if policy['from']['protocols'] is not defined and policy['from']['pfx_list'] is not defined %}
set policy-options policy-statement {{ policy['name'] }} then {{ policy['action'] }}
{% endif %}
{% endfor %}

There is a lot happening in this template. First, we go through our list of route policies (in our host_vars) and delete the existing policy (to remove old/outdated configuration).

Once this is done, we generate the new policies. Our options are: -

  • No prefix-lists matched and matching a protocol (e.g. OSPF, IS-IS)
    • We can also match against static (static routes) or direct (directly connected networks)
  • Prefix-lists matched and matching a protocol
  • Prefix-lists matched and not matching a protocol
  • No protocols or prefix-lists defined

What this means is that our policies can do the following: -

  • Import all routes from another protocol
  • Import some routes from another protocol, using prefix lists to limit which routes
  • Import routes solely based upon a prefix list
  • Purely allow all routes or deny all routes

Also, we apply terms (i.e. different sections of the policy) for IPv4 and IPv6. If you attempt to apply route policy without these to an IPv4 peer, then it will fail as the policy also containers IPv6 prefix-lists. The same applies for an IPv6 peer when a route policy containers IPv4 prefix-lists. Using the term v4 and term v6 statements allows the correct prefix-lists to be applied to the correct peers.

To help explain, we will look at our host_vars, and then go through each policy that is generated.

junos-01 host_vars

route_policies:
  rp:
    - name: external_networks
      from:
        protocols:
        - bgp
        pfx_list: external_nets
      action: accept
    - name: internal_networks
      from:
        protocols:
        - direct
        - ospf
        - ospf3
        pfx_list: internal_nets
      action: accept
    - name: dhcp_default
      from:
        protocols:
        - access-internal
      action: accept
    - name: deny-all
      action: reject

For the policy named external_networks, if the routes are received from BGP, and match the prefix-list external_nets, then the routes are accepted.

For the policy named internal_networks, routes from OSPF, OSPFv3 and directly connected networks are accepted that match the internal_nets prefix list.

For the policy named dhcp_default, all routes from the protocol access-internal are accepted. This is the protocol JunOS uses when a default route is received via DHCP.

For the policy deny-all, all routes are rejected, no matter the protocol or prefix.

The actual configuration that is generated looks like the below: -

set policy-options policy-statement deny-all then reject
set policy-options policy-statement dhcp_default from protocol access-internal
set policy-options policy-statement dhcp_default then accept
set policy-options policy-statement external_networks term v4 from protocol bgp
set policy-options policy-statement external_networks term v4 from prefix-list external_nets_v4
set policy-options policy-statement external_networks term v4 then accept
set policy-options policy-statement external_networks term v6 from protocol bgp
set policy-options policy-statement external_networks term v6 from prefix-list external_nets_v6
set policy-options policy-statement external_networks term v6 then accept
set policy-options policy-statement internal_networks term v4 from protocol direct
set policy-options policy-statement internal_networks term v4 from protocol ospf
set policy-options policy-statement internal_networks term v4 from protocol ospf3
set policy-options policy-statement internal_networks term v4 from prefix-list internal_nets_v4
set policy-options policy-statement internal_networks term v4 then accept
set policy-options policy-statement internal_networks term v6 from protocol direct
set policy-options policy-statement internal_networks term v6 from protocol ospf
set policy-options policy-statement internal_networks term v6 from protocol ospf3
set policy-options policy-statement internal_networks term v6 from prefix-list internal_nets_v6
set policy-options policy-statement internal_networks term v6 then accept
Configuring BGP peers

Ansible module: junos_config

This task creates the BGP peers. JunOS requires that all peers are part of a group. This differs from IOS, in that Cisco does allow you to configure peer groups, but a BGP peer does not have to be part of one.

The template used to generate the peers is below: -

set protocols bgp local-as {{ bgp['local_as'] }}
{% for group in bgp['groups'] %}
delete protocols bgp group {{ group['name'] }}
set protocols bgp group {{ group['name'] }} type {{ group['type'] }}
set protocols bgp group {{ group['name'] }} description "{{ group['desc'] }}"
set protocols bgp group {{ group['name'] }} hold-time {{ group['hold'] }}
set protocols bgp group {{ group['name'] }} log-updown
{% for policy in group['policies']['import'] %}
set protocols bgp group {{ group['name'] }} import {{ policy }}
{% endfor %}
{% for policy in group['policies']['export'] %}
set protocols bgp group {{ group['name'] }} export {{ policy }}
{% endfor %}
{% for neighbour in group['neighbours'] %}
{% if 'external' in group['type'] %}
set protocols bgp group {{ group['name'] }} neighbor {{ neighbour['peer'] }} description "{{ neighbour['desc'] }}"
set protocols bgp group {{ group['name'] }} neighbor {{ neighbour['peer'] }} peer-as {{ neighbour['remote_as'] }}
{% else %}
set protocols bgp group {{ group['name'] }} neighbor {{ neighbour['peer'] }} description "{{ neighbour['desc'] }}"
set protocols bgp group {{ group['name'] }} neighbor {{ neighbour['peer'] }} local-address "{{ neighbour['loc_ip'] }}"
{% endif %}
{% endfor %}
{% endfor %}

With this, we are setting our local autonomous system, building a group (and setting up our base parameters), and then add our peers to it. This is also where we apply our import and export policies. We then will add our BGP peers, and will specify either the neighbour’s autonomous system number (if it is an external neighbour) or the address that the BGP session is formed from (e.g. from our loopback IP address).

The below host_vars are used for this: -

bgp:
  local_as: 65102
  groups:
    - name: NETSVR
      type: external
      desc: "Peering to NETSVR"
      hold: 30
      policies:
        import:
          - external_networks
          - deny-all
        export:
          - internal_networks
          - deny-all
      neighbours:
        - peer: 10.100.102.254
          remote_as: 65430
          desc: "netsvr-01 IPv4"
        - peer: "2001:db8:102::ffff"
          remote_as: 65430
          desc: "netsvr-01 IPv6"
    - name: IBGP
      type: internal
      desc: "IBGP BGP Peering"
      hold: 30
      policies:
        export:
          - dhcp_default
          - external_networks
          - deny-all
        import:
          - internal_networks
          - deny-all
      neighbours:
        - peer: 192.0.2.202
          loc_ip: 192.0.2.102
          default_originate: true
          desc: "junos-02 IPv4"
        - peer: "2001:db8:902:beef::2"
          loc_ip: "2001:db8:902:beef::1"
          desc: "junos-02 IPv6"

This will then generate the following configuration: -

set protocols bgp group NETSVR type external
set protocols bgp group NETSVR description "Peering to NETSVR"
set protocols bgp group NETSVR hold-time 30
set protocols bgp group NETSVR log-updown
set protocols bgp group NETSVR import external_networks
set protocols bgp group NETSVR import deny-all
set protocols bgp group NETSVR export internal_networks
set protocols bgp group NETSVR export deny-all
set protocols bgp group NETSVR neighbor 10.100.102.254 description "netsvr-01 IPv4"
set protocols bgp group NETSVR neighbor 10.100.102.254 peer-as 65430
set protocols bgp group NETSVR neighbor 2001:db8:102::ffff description "netsvr-01 IPv6"
set protocols bgp group NETSVR neighbor 2001:db8:102::ffff peer-as 65430
set protocols bgp group IBGP type internal
set protocols bgp group IBGP description "IBGP BGP Peering"
set protocols bgp group IBGP hold-time 30
set protocols bgp group IBGP log-updown
set protocols bgp group IBGP import internal_networks
set protocols bgp group IBGP import deny-all
set protocols bgp group IBGP export dhcp_default
set protocols bgp group IBGP export external_networks
set protocols bgp group IBGP export deny-all
set protocols bgp group IBGP neighbor 192.0.2.202 description "junos-02 IPv4"
set protocols bgp group IBGP neighbor 192.0.2.202 local-address 192.0.2.102
set protocols bgp group IBGP neighbor 2001:db8:902:beef::2 description "junos-02 IPv6"
set protocols bgp group IBGP neighbor 2001:db8:902:beef::2 local-address 2001:db8:902:beef::1
set protocols bgp local-as 65102

As noted, we do not need to configure our IPv4 and IPv6 neighbours in different address families or sections of configuration. They can all be configure within the same group.

Redistribution

A point to note with JunOS is that redistribution from another protocol works entirely differently from Cisco IOS. In Cisco IOS, redistribution imports routes from one protocol into another (say, importing your OSPF routes into BGP).

This is not the case for JunOS. Instead, when you apply an export policy, it is applied to the routes in your routing table. Those that match the policy are then advertised to the BGP neighbours in the group.

What this means is that you can selectively advertise routes to peers, rather than in IOS where you would be redistributing the routes from another protocol, and then filtering what each neighbour advertises.

Because of this, we do not need to add specific configuration for redistribution (as we do in Cisco IOS).

Verification

After all of the above has run, we should have BGP sessions up over IPv4 and IPv6, as well as routes received and sent to netsvr-01 BGP route server.

junos-01

! Show BGP neighbours on IPv4 and IPv6
ansible@junos-01> show bgp summary
Threading mode: BGP I/O
Groups: 2 Peers: 4 Down peers: 0
Table          Tot Paths  Act Paths Suppressed    History Damp State    Pending
inet.0
                       1          1          0          0          0          0
inet6.0
                       1          1          0          0          0          0
Peer                     AS      InPkt     OutPkt    OutQ   Flaps Last Up/Dwn State|#Active/Received/Accepted/Damped...
10.100.102.254        65430        302        330       0       0       49:31 Establ
  inet.0: 1/1/1/0
192.0.2.202           65102        327        328       0       0       48:46 Establ
  inet.0: 0/0/0/0
2001:db8:102::ffff       65430        301        329       0       0       49:24 Establ
  inet6.0: 1/1/1/0
2001:db8:902:beef::2       65102        325        322       0       0       48:00 Establ
  inet6.0: 0/0/0/0

! Show BGP routes
ansible@junos-01> show route protocol bgp

inet.0: 13 destinations, 13 routes (13 active, 0 holddown, 0 hidden)
+ = Active Route, - = Last Active, * = Both

192.0.2.1/32       *[BGP/170] 00:50:08, MED 0, localpref 100
                      AS path: 65430 I, validation-state: unverified
                    >  to 10.100.102.254 via ge-0/0/0.102

inet6.0: 12 destinations, 12 routes (12 active, 0 holddown, 0 hidden)
+ = Active Route, - = Last Active, * = Both

2001:db8:999:beef::1/128
                   *[BGP/170] 00:50:01, MED 0, localpref 100
                      AS path: 65430 I, validation-state: unverified
                    >  to 2001:db8:102::ffff via ge-0/0/0.102

! Ping the netsvr Loopback (192.0.2.1 and 2001:DB8:999:BEEF::1)
ansible@junos-01> ping 192.0.2.1
PING 192.0.2.1 (192.0.2.1): 56 data bytes
64 bytes from 192.0.2.1: icmp_seq=0 ttl=64 time=0.963 ms
64 bytes from 192.0.2.1: icmp_seq=1 ttl=64 time=0.556 ms
^C
--- 192.0.2.1 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.556/0.760/0.963/0.203 ms

ansible@junos-01> ping 2001:db8:999:beef::1
PING6(56=40+8+8 bytes) 2001:db8:102::f --> 2001:db8:999:beef::1
16 bytes from 2001:db8:999:beef::1, icmp_seq=0 hlim=64 time=0.934 ms
16 bytes from 2001:db8:999:beef::1, icmp_seq=1 hlim=64 time=0.542 ms
16 bytes from 2001:db8:999:beef::1, icmp_seq=2 hlim=64 time=0.526 ms
^C
--- 2001:db8:999:beef::1 ping6 statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max/std-dev = 0.526/0.667/0.934/0.189 ms

junos-02

! Show BGP neighbours on IPv4 and IPv6
ansible@junos-02> show bgp summary
Threading mode: BGP I/O
Groups: 1 Peers: 2 Down peers: 0
Table          Tot Paths  Act Paths Suppressed    History Damp State    Pending
inet.0
                       2          2          0          0          0          0
inet6.0
                       1          1          0          0          0          0
Peer                     AS      InPkt     OutPkt    OutQ   Flaps Last Up/Dwn State|#Active/Received/Accepted/Damped...
192.0.2.102           65102         11          8       0       0          59 Establ
  inet.0: 2/2/2/0
2001:db8:902:beef::1       65102          5          2       0       0          13 Establ
  inet6.0: 1/1/1/0

! Show BGP routes
ansible@junos-02> show route protocol bgp

inet.0: 11 destinations, 11 routes (11 active, 0 holddown, 0 hidden)
+ = Active Route, - = Last Active, * = Both

0.0.0.0/0          *[BGP/170] 00:01:21, MED 0, localpref 100, from 192.0.2.102
                      AS path: I, validation-state: unverified
                    >  to 10.100.202.254 via ge-0/0/0.202
192.0.2.1/32       *[BGP/170] 00:01:21, MED 0, localpref 100, from 192.0.2.102
                      AS path: 65430 I, validation-state: unverified
                    >  to 10.100.202.254 via ge-0/0/0.202

inet6.0: 10 destinations, 10 routes (10 active, 0 holddown, 0 hidden)
+ = Active Route, - = Last Active, * = Both

2001:db8:999:beef::1/128
                   *[BGP/170] 00:00:35, MED 0, localpref 100, from 2001:db8:902:beef::1
                      AS path: 65430 I, validation-state: unverified
                    >  to fe80::5254:0:cafe:ea97 via ge-0/0/0.202

! Ping the netsvr Loopback (192.0.2.1 and 2001:DB8:999:BEEF::1)
ansible@junos-02> ping 192.0.2.1
PING 192.0.2.1 (192.0.2.1): 56 data bytes
64 bytes from 192.0.2.1: icmp_seq=0 ttl=63 time=1.764 ms
64 bytes from 192.0.2.1: icmp_seq=1 ttl=63 time=0.678 ms
64 bytes from 192.0.2.1: icmp_seq=2 ttl=63 time=0.732 ms
^C
--- 192.0.2.1 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.678/1.058/1.764/0.500 ms

ansible@junos-02> ping 2001:db8:999:beef::1 source 2001:db8:902:beef::2
PING6(56=40+8+8 bytes) 2001:db8:902:beef::2 --> 2001:db8:999:beef::1
16 bytes from 2001:db8:999:beef::1, icmp_seq=0 hlim=63 time=2.212 ms
16 bytes from 2001:db8:999:beef::1, icmp_seq=1 hlim=63 time=2.049 ms
16 bytes from 2001:db8:999:beef::1, icmp_seq=2 hlim=63 time=0.875 ms
^C
--- 2001:db8:999:beef::1 ping6 statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max/std-dev = 0.875/1.712/2.212/0.596 ms

All looking good!

SNMP

In this section, we enable SNMP, so that we can monitor the routers. Again, no native Ansible module exists, so we are using the junos_config module.

Playbook

The contents of the playbook are below: -

---
## tasks file for snmp
- name: Enable SNMPv3
  junos_config:
    src: snmpv3.j2
  tags:
    - snmp

Template

The template for this module looks like the below: -

delete snmp
set snmp contact "{{ snmp['contact'] }}"
set snmp location "{{ snmp['location'] }}"
set snmp v3 usm local-engine user {{ snmp['user'] }} authentication-sha authentication-password {{ snmp['auth_key'] }}
set snmp v3 usm local-engine user {{ snmp['user'] }} privacy-aes128 privacy-password {{ snmp['priv_key'] }}
set snmp v3 vacm security-to-group security-model usm security-name {{ snmp['user'] }} group {{ snmp['group'] }}
set snmp v3 vacm access group {{ snmp['group'] }} default-context-prefix security-model any security-level authentication read-view all
set snmp v3 vacm access group {{ snmp['group'] }} default-context-prefix security-model any security-level authentication write-view all
set snmp view all oid .1

We don’t need to use any loops for this, so the template is almost identical to the generated configuration (other than some variables that will be replaced). If you wanted more than one SNMPv3 user and/or group, then you would need to add loops to this.

We use group_vars for this, rather than host_vars, as the same SNMPv3 user is used on both the edge router and the internal router

snmp:
  location: Yeti Home
  contact: The Hairy One
  user: yetiops
  group: yetiops_group
  auth_key: !vault |
            $ANSIBLE_VAULT;1.1;AES256
            383###REDACTED###############################################################566
            363###REDACTED###############################################################366
            343###REDACTED###############################################################565
            326###REDACTED###############################################################362
            3431
  priv_key: !vault |
            $ANSIBLE_VAULT;1.1;AES256
            383###REDACTED###############################################################566
            363###REDACTED###############################################################366
            343###REDACTED###############################################################565
            326###REDACTED###############################################################362
            3431

We use Ansible Vault again, so that we can store our credentials in version control, without storing them unencrypted.

The generated configuration looks like the below: -

set snmp location "Yeti Home"
set snmp contact "The Hairy One"
set snmp v3 usm local-engine user yetiops authentication-sha authentication-password ###REDACTED###
set snmp v3 usm local-engine user yetiops privacy-aes128 privacy-password ###REDACTED###
set snmp v3 vacm security-to-group security-model usm security-name yetiops group yetiops_group
set snmp v3 vacm access group yetiops_group default-context-prefix security-model any security-level authentication read-view all
set snmp v3 vacm access group yetiops_group default-context-prefix security-model any security-level authentication write-view all
set snmp view all oid .1

Verification

To check whether this is working, you will need either some form of monitoring system, or you can use something like snmpwalk to check: -

! snmpwalk to junos-01
$ snmpwalk -v3 -u yetiops -a SHA -A ###AUTH-KEY### -x AES -X ###PRIV-KEY### -l authPriv 10.15.30.33
iso.3.6.1.2.1.1.1.0 = STRING: "Juniper Networks, Inc. vsrx internet router, kernel JUNOS 19.2R1.8, Build date: 2019-06-21 21:03:26 UTC Copyright (c) 1996-2019 Juniper Networks, Inc."
iso.3.6.1.2.1.1.2.0 = OID: iso.3.6.1.4.1.2636.1.1.1.2.129
iso.3.6.1.2.1.1.3.0 = Timeticks: (124672) 0:20:46.72
iso.3.6.1.2.1.1.4.0 = STRING: "The Hairy One"
iso.3.6.1.2.1.1.5.0 = STRING: "junos-01"
iso.3.6.1.2.1.1.6.0 = STRING: "Yeti Home"
iso.3.6.1.2.1.1.7.0 = INTEGER: 4
iso.3.6.1.2.1.2.1.0 = INTEGER: 37
iso.3.6.1.2.1.2.2.1.1.1 = INTEGER: 1
iso.3.6.1.2.1.2.2.1.1.4 = INTEGER: 4
iso.3.6.1.2.1.2.2.1.1.5 = INTEGER: 5
iso.3.6.1.2.1.2.2.1.1.6 = INTEGER: 6
iso.3.6.1.2.1.2.2.1.1.7 = INTEGER: 7

! snmpwalk to junos-02
$ snmpwalk -v3 -u yetiops -a SHA -A ###AUTH-KEY### -x AES -X ###PRIV-KEY### -l authPriv 10.15.30.34
iso.3.6.1.2.1.1.1.0 = STRING: "Juniper Networks, Inc. vsrx internet router, kernel JUNOS 19.2R1.8, Build date: 2019-06-21 21:03:26 UTC Copyright (c) 1996-2019 Juniper Networks, Inc."
iso.3.6.1.2.1.1.2.0 = OID: iso.3.6.1.4.1.2636.1.1.1.2.129
iso.3.6.1.2.1.1.3.0 = Timeticks: (132339) 0:22:03.39
iso.3.6.1.2.1.1.4.0 = STRING: "The Hairy One"
iso.3.6.1.2.1.1.5.0 = STRING: "junos-02"
iso.3.6.1.2.1.1.6.0 = STRING: "Yeti Home"
iso.3.6.1.2.1.1.7.0 = INTEGER: 4
iso.3.6.1.2.1.2.1.0 = INTEGER: 34
iso.3.6.1.2.1.2.2.1.1.1 = INTEGER: 1
iso.3.6.1.2.1.2.2.1.1.4 = INTEGER: 4
iso.3.6.1.2.1.2.2.1.1.5 = INTEGER: 5
iso.3.6.1.2.1.2.2.1.1.6 = INTEGER: 6
iso.3.6.1.2.1.2.2.1.1.7 = INTEGER: 7

All looks good again!

NAT

In this section, we are allowing the internal router to reach the internet, via the edge router. The edge router has a default route to the internet that is learned from DHCP.

Playbook

The playbook looks like the below: -

---
## tasks file for nat
- name: Apply NAT Overload
  junos_config:
    src: nat-overload.j2
  tags:
  - nat

Again, we are using junos_config for this, as no Ansible module exists for NAT on JunOS.

Template

The template looks like the below: -

{% if 'edge' in rtr_role %}
delete security nat
{% for zone in zones %}
{% if zone['nat'] is defined %}
set security nat source rule-set internet-nat description "Internet-facing NAT"
{% if 'inside' in zone['nat']['role'] %}
set security nat source rule-set internet-nat from zone {{ zone['name'] }}
{% endif %}
{% if 'outside' in zone['nat']['role'] %}
set security nat source rule-set internet-nat to zone {{ zone['name'] }}
{% endif %}
{% endif %}
{% endfor %}
set security nat source rule-set internet-nat rule snat-out match destination-address 0.0.0.0/0
set security nat source rule-set internet-nat rule snat-out then source-nat interface
{% endif %}

The above template, like in previous tasks, removes all the existing NAT configuration. Unlike in Cisco IOS where you apply access lists, NAT configuration and define NAT on your interfaces, in JunOS all the configuration is in one place. NAT is also applied between zones, rather than specifying individual interfaces.

Our host_vars for this looks like the below: -

zones:
  - name: "internet"
    nat:
      role: "outside"
  - name: "internal"
    nat:
      role: "inside"

This generates the following configuration: -

set security nat source rule-set internet-nat description "Internet-facing NAT"
set security nat source rule-set internet-nat from zone internal
set security nat source rule-set internet-nat to zone internet
set security nat source rule-set internet-nat rule snat-out match destination-address 0.0.0.0/0
set security nat source rule-set internet-nat rule snat-out then source-nat interface

Verification

We can now test from the internal router, to see if it can reach the internet: -

! Can we reach the internet?
ansible@junos-02> ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=52 time=22.939 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=52 time=18.661 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=52 time=20.108 ms
^C
--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max/stddev = 18.661/20.569/22.939/1.777 ms

ansible@junos-02> ping 1.1.1.1
PING 1.1.1.1 (1.1.1.1): 56 data bytes
64 bytes from 1.1.1.1: icmp_seq=0 ttl=55 time=16.593 ms
64 bytes from 1.1.1.1: icmp_seq=1 ttl=55 time=30.943 ms
^C
--- 1.1.1.1 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/stddev = 16.593/23.768/30.943/7.175 ms

ansible@junos-02> ping 1.1.1.1 source 192.0.2.202
PING 1.1.1.1 (1.1.1.1): 56 data bytes
64 bytes from 1.1.1.1: icmp_seq=0 ttl=55 time=14.568 ms
64 bytes from 1.1.1.1: icmp_seq=1 ttl=55 time=14.456 ms
^C
--- 1.1.1.1 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/stddev = 14.456/14.512/14.568/0.056 ms

! What does this look like on the edge router?
ansible@junos-01> show security nat source rule snat-out
source NAT rule: snat-out               Rule-set: internet-nat
  Rule-Id                    : 1
  Rule position              : 1
  From zone                  : internal
  To zone                    : internet
    Destination addresses    : 0.0.0.0         - 255.255.255.255
  Action                        : interface
    Persistent NAT type         : N/A
    Persistent NAT mapping type : address-port-mapping
    Inactivity timeout          : 0
    Max session number          : 0
  Translation hits           : 68
    Successful sessions      : 68
    Failed sessions          : 0
  Number of sessions         : 5

All looking good, the translation hits showing every time that NAT is being used.

AAA

The final task is AAA (Authentication, Authorization and Accounting). This will run against the tac_plus instance on the netsvr-01 machine.

This will allow central management of our users, as well as providing logs of commands being run.

Again, no Ansible module exists for AAA, so we are using the junos_config module.

Playbook

The playbook is one task again, applying a template: -

---
## tasks file for aaa
- name: Enable TACACS+
  junos_config:
    src: tacacs.j2
  tags: 
  - aaa

Template

The template used can be seen below: -

set system authentication-order password
set system authentication-order tacplus
set system tacplus-server {{ tacacs['ipv4'] }} secret {{ tacacs['secret'] }} 
set system login class super-user-local idle-timeout 3600
set system login class super-user-local permissions all
set system login user remote full-name "Any remote"
set system login user remote uid 2002
set system login user remote class super-user-local
set system accounting events login
set system accounting events change-log
set system accounting events interactive-commands
set system accounting destination tacplus server {{ tacacs['ipv4'] }} secret {{ tacacs['secret'] }} 

Compared to the IOS template, there are far fewer commands here to enable TACACS+ authentication. We also set the authentication-order, to allow users defined locally (i.e. those on the JunOS router itself) access before querying tac_plus. This is useful in situations where tac_plus may be functioning incorrectly, but JunOS still thinks it is available.

Again, from our group_vars, we apply the following: -

tacacs:
  ipv4: 192.0.2.1
  secret: supersecret

This then generates the following configuration: -

set system authentication-order password
set system authentication-order tacplus
set system tacplus-server 192.0.2.1 secret "$9$7lV2ajHmPT3wYfz6/OBcylMWx-VYJZjs2"
set system login class super-user-local idle-timeout 3600
set system login class super-user-local permissions all
set system login user remote full-name "Any remote"
set system login user remote uid 2002
set system login user remote class super-user-local

Verification

! Can we login with the yetiops user?
$ ssh [email protected]
----------------------------------------
|
| This banner was generated by Ansible 
|
----------------------------------------
|
| You are logged into junos-02
| 
----------------------------------------
Password:
--- JUNOS 19.2R1.8 Kernel 64-bit XEN JNPR-11.0-20190517.f0321c3_buil
yetiops@junos-02> 

! What about a user that doesn't exist?
$ ssh [email protected]                                                                                     
----------------------------------------
|
| This banner was generated by Ansible 
|
----------------------------------------
|
| You are logged into junos-02
| 
----------------------------------------
Password:
Password:
Password:

! What do see in our accounting log?
Mar 31 19:41:28	10.100.102.253	yetiops	0	10.15.30.1	start	task_id=1	service=shell	session_pid = 8488	cmd=login
Mar 31 19:41:33	10.100.102.253	yetiops	0	10.15.30.1	stop	task_id=2	service=shell	session_pid = 8488	cmd=show route <cr>
Mar 31 19:41:33	10.100.102.253	yetiops	0	10.15.30.1	stop	task_id=3	service=shell	session_pid = 8488	cmd=configure <cr>
Mar 31 19:41:33	10.100.102.253	yetiops	0	10.15.30.1	stop	task_id=4	service=shell	session_pid = 8488	cmd=exit <cr>

! What about if the TACACS+ server goes away?
[stuh84@netsvr-01 /var/log] $ sudo systemctl stop tac_plus
[stuh84@netsvr-01 /var/log] $ sudo systemctl status tac_plus
tac_plus.service - LSB: TACACS+ server based on Cisco source release
   Loaded: loaded (/etc/rc.d/init.d/tac_plus; generated)
   Active: inactive (dead) since Tue 2020-03-31 14:42:38 EDT; 3s ago

$ ssh [email protected]
----------------------------------------
|
| This banner was generated by Ansible 
|
----------------------------------------
|
| You are logged into junos-02
| 
----------------------------------------
Password:
Last login: Tue Mar 31 18:05:40 2020 from 10.15.30.1
--- JUNOS 19.2R1.8 Kernel 64-bit XEN JNPR-11.0-20190517.f0321c3_buil
ansible@junos-02> 

All looks good!

Parent playbook

The parent playbook (i.e. the playbook that brings all the roles together) is below: -

---
- hosts: junos
  gather_facts: false
  tasks:
    - import_role:
        name: system
    - import_role:
        name: interfaces
    - import_role:
        name: firewall
    - import_role:
        name: routing
    - import_role:
        name: snmp
    - import_role:
        name: nat
    - import_role:
        name: aaa

We do not need to have a task that saves the configuration after running, because the changes are committed as part of each task.

Other options for committing configuration

JunOS will not let you commit changes that are missing vital parts of configuration, or that are malformed. If you choose, you can also use the following values to control your commits: -

---
## tasks file for aaa
- name: Enable TACACS+
  junos_config:
    src: tacacs.j2
    commit: 5
    check_commit: yes
  tags: 
  - aaa

What the above will do is: -

  • Run a commit check to ensure that the candidate configuration being committed is valid
    • This is quicker than running through the full commit process
  • Use the commit confirmed 5 option to say that unless commit is supplied again with 5 minutes, the configuration reverts to its previous state.

You can either commit the changes yourself, or you can run a task like the below to confirm the commit: -

- name: confirm a previous commit
  junos_config:
    confirm_commit: yes

You could do this further down a playbook, or it could be invoked later. This gives time for any routing protocol changes to converge, or for a potentially broken change to take effect, rather than committing straight away and losing access to the device.

The commit system is one of my favourite JunOS features, as well as the ability to preview your changes before committing (show | compare within configuration mode).

Role Order

The role order is very similar to how I explained for IOS. The main difference here is that we are using stateful firewalling rather than access lists. The justification for the role order is detailed within Part 3 - Cisco IOS. To summarize: -

  • system - Sets up logging, hostnames and banners
    • This ensure we have logging ready for if any of the other roles fail (that the Ansible debug output cannot help with)
  • interfaces - This is a prerequisite for most of the following tasks
  • firewall - Apply before routing so that the device is not open to the world when publicly routable
  • routing - Routing is required for NAT and AAA to function
  • snmp - No dependency on any service, so this can go anywhere
  • nat - Apply this after routing, otherwise the internal router has no default route to reach external destinations anyway
  • aaa - It depends upon routing, and if configured incorrectly it can break login sessions

Running aaa last means that all other configuration is complete, so that if login sessions do break, you at least have a fully configured router (and therefore able to access via other means) before this happens.

Artifacts

The final directory structure looks like the below: -

$ tree -L 2
.
├── ansible.cfg
├── ansible.log
├── group_vars
│   └── junos
├── host_vars
│   ├── junos-01.yml
│   └── junos-02.yml
├── inventory
├── junos.yaml
└── roles
    ├── aaa
    ├── firewall
    ├── interfaces
    ├── lldp
    ├── nat
    ├── routing
    ├── snmp
    └── system

11 directories, 7 files

The final contents of our group_vars are: -

ansible_user: ansible
ansible_connection: netconf 
ansible_network_os: junos
ansible_ssh_pass: !vault |
                  $ANSIBLE_VAULT;1.1;AES256
                  383###REDACTED###############################################################566
                  363###REDACTED###############################################################366
                  343###REDACTED###############################################################565
                  326###REDACTED###############################################################362
                  3431
log_host: 10.100.101.254
tacacs:
  ipv4: 192.0.2.1
  secret: supersecret
snmp:
  location: Yeti Home
  contact: The Hairy One
  user: yetiops
  group: yetiops_group
  auth_key: !vault |
            $ANSIBLE_VAULT;1.1;AES256
            383###REDACTED###############################################################566
            363###REDACTED###############################################################366
            343###REDACTED###############################################################565
            326###REDACTED###############################################################362
            3431
  priv_key: !vault |
            $ANSIBLE_VAULT;1.1;AES256
            386###REDACTED###############################################################764
            613###REDACTED###############################################################630
            646###REDACTED###############################################################331
            376###REDACTED###############################################################137
            3563

The final contents of our host_vars are: -

junos-01.yaml

router_id: 192.0.2.102
rtr_role: edge
addressbook:
  - zone: edge
    name: netsvr
    ip: 10.100.102.254
    set:
      - external_bgp_peers
      - netsvr-direct
  - zone: edge
    name: netsvr-v6
    ip: "2001:db8:102::ffff/128"
    set:
      - external_bgp_peers
      - netsvr-direct
  - zone: edge
    name: netsvr-lo
    ip: 192.0.2.1
    set:
    - netsvr-loop
  - zone: edge
    name: netsvr-lo-v6
    ip: "2001:db8:999:beef::1/128"
    set: 
    - netsvr-loop
  - zone: internal
    name: internal-rtr
    ip: 192.0.2.202
    set: 
    - internal_bgp_peers
    - internal-rtr-loop
  - zone: internal
    name: internal-rtr-v6
    ip: "2001:db8:902:beef::2/128"
    set: 
    - internal_bgp_peers
    - internal-rtr-loop
fw_policies:
  - name: all_icmp
    zone: global
    source: any
    destination: any
    action: permit
    apps:
      - junos-ping
  - name: a_tacacs
    zone: internal
    from_zone: internal
    to_zone: edge
    source: any
    destination: 
      - netsvr-loop
    action: permit
    apps:
      - junos-tacacs
  - name: a_syslog
    zone: internal
    from_zone: internal
    to_zone: edge
    source: any
    destination: 
      - netsvr-direct
    action: permit
    apps:
      - junos-syslog
  - name: a_ext_bgp
    zone: edge
    from_zone: edge
    to_zone: edge
    source: 
      - external_bgp_peers
    destination: any
    action: permit
    apps:
      - junos-bgp
  - name: a_ext_bgp_host
    zone: edge
    from_zone: edge
    to_zone: junos-host
    source: 
      - external_bgp_peers
    destination: any
    action: permit
    apps:
      - junos-bgp
  - name: a_int_bgp
    zone: internal
    from_zone: internal
    to_zone: internal
    source: 
      - internal_bgp_peers
    destination: any
    action: permit
    apps:
      - junos-bgp
  - name: a_int_bgp_host
    zone: internal
    from_zone: internal
    to_zone: junos-host
    source: 
      - internal_bgp_peers
    destination: any
    action: permit
    apps:
      - junos-bgp
  - name: a_internet_routing
    zone: internal
    from_zone: internal
    to_zone: internal
    source: any
    destination: any
    action: permit
    apps: any
route_policies:
  prefix_lists:
    - name: internal_nets
      addresses:
         - 192.0.2.102/32
         - 192.0.2.202/32
         - 10.100.202.0/24
         - "2001:db8:902:beef::1/128"
         - "2001:db8:902:beef::2/128"
         - "2001:db8:902::/64"
    - name: external_nets
      addresses:
         - 192.0.2.1/32
         - "2001:db8:999:beef::1/128"
         - 10.100.102.0/24
  rp:
    - name: external_networks
      from:
        protocols: 
        - bgp
        pfx_list: external_nets
      action: accept
    - name: internal_networks
      from:
        protocols:
        - direct
        - ospf
        - ospf3
        pfx_list: internal_nets
      action: accept
    - name: dhcp_default
      from:
        protocols: 
        - access-internal
      action: accept
    - name: deny-all 
      action: reject
bgp:
  local_as: 65102
  groups:
    - name: NETSVR
      type: external
      desc: "Peering to NETSVR"
      hold: 30
      policies:
        import:
          - external_networks
          - deny-all
        export: 
          - internal_networks
          - deny-all
      neighbours:
        - peer: 10.100.102.254
          remote_as: 65430
          desc: "netsvr-01 IPv4"
        - peer: "2001:db8:102::ffff"
          remote_as: 65430
          desc: "netsvr-01 IPv6"
    - name: IBGP
      type: internal
      desc: "IBGP BGP Peering"
      hold: 30
      policies:
        export:
          - dhcp_default
          - external_networks
          - deny-all
        import: 
          - internal_networks
          - deny-all
      neighbours:
        - peer: 192.0.2.202
          loc_ip: 192.0.2.102
          default_originate: true
          desc: "junos-02 IPv4"
        - peer: "2001:db8:902:beef::2"
          loc_ip: "2001:db8:902:beef::1"
          desc: "junos-02 IPv6"
zones:
  - name: "edge"
    host_traffic:
      protocols:
        - bgp
      services:
        - ping
        - traceroute
  - name: "internet"
    nat: 
      role: "outside"
    host_traffic:
      services:
        - ping
        - traceroute
        - dhcp
  - name: "internal"
    nat:
      role: "inside"
    host_traffic:
      protocols:
        - bgp
        - ospf
        - ospf3
      services:
        - ping
        - traceroute
interfaces:
  - junos_if: "fxp0"
    unit: 0
    desc: "Management"
    enabled: "true"
    ipv4_addr: "10.15.30.33/24"
  - junos_if: "ge-0/0/0"
    unit: 0
    desc: "VLAN Bridge"
    enabled: "true"
    subint:
      vlans:
      - 102
      - 202
  - junos_if: "ge-0/0/0"
    unit: 102
    desc: "To netsvr"
    enabled: "true"
    if_zone: "edge"
    ipv4_addr: "10.100.102.253/24"
    ipv6_addr: "2001:db8:102::f/64"
    ospf:
      area: "0.0.0.0"
      passive: true
    ospfv3:
      area: "0.0.0.0"
      passive: true
  - junos_if: "ge-0/0/0"
    unit: 202
    desc: "To junos-02"
    enabled: "true"
    ipv4_addr: "10.100.202.254/24"
    ipv6_addr: "2001:db8:202::a/64"
    if_zone: "internal"
    ospf:
      area: "0.0.0.0"
    ospfv3:
      area: "0.0.0.0"
  - junos_if: "ge-0/0/1"
    desc: "To the Internet"
    unit: 0
    enabled: "true"
    ipv4_addr: "dhcp"
    if_zone: "internet"
    ospf:
      area: "0.0.0.0"
      passive: true
  - junos_if: "lo0"
    unit: 0
    desc: "Loopback"
    enabled: "true"
    if_zone: "internal"
    ipv4_addr: "192.0.2.102/32"
    ipv6_addr: "2001:db8:902:beef::1/128"
    ospf:
      area: "0.0.0.0"
      passive: true
    ospfv3:
      area: "0.0.0.0"
      passive: true

junos-02.yaml

router_id: 192.0.2.202
rtr_role: internal
addressbook:
  - zone: internal
    name: edge-rtr
    ip: 192.0.2.102
    set: 
    - internal_bgp_peers
    - internal-rtr-loop
  - zone: internal
    name: edge-rtr-v6
    ip: "2001:db8:902:beef::1/128"
    set: 
    - internal_bgp_peers
    - internal-rtr-loop
fw_policies:
  - name: all_icmp
    zone: global
    source: any
    destination: any
    action: permit
    apps:
      - junos-ping
  - name: a_int_bgp
    zone: internal
    from_zone: internal
    to_zone: internal
    source: 
      - internal_bgp_peers
    destination: any
    action: permit
    apps:
      - junos-bgp
  - name: a_int_bgp_host
    zone: internal
    from_zone: internal
    to_zone: junos-host
    source: 
      - internal_bgp_peers
    destination: any
    action: permit
    apps:
      - junos-bgp
route_policies:
  prefix_lists:
    - name: external_nets
      addresses:
         - 192.0.2.1/32
         - "2001:db8:999:beef::1/128"
         - 10.100.102.0/24
    - name: internal_nets
      addresses:
         - 192.0.2.102/32
         - 192.0.2.202/32
         - 10.100.202.0/24
         - "2001:db8:902:beef::1/128"
         - "2001:db8:902:beef::2/128"
         - "2001:db8:902::/64"
    - name: default_route
      addresses:
        - 0.0.0.0/0
  rp:
    - name: external_networks
      from:
        protocols: 
        - bgp
        pfx_list: external_nets
      action: accept
    - name: internal_networks
      from:
        protocols:
        - bgp
        pfx_list: internal_nets
      action: accept
    - name: default_route
      from:
        protocols: 
        - bgp 
        pfx_list: default_route
      action: accept
    - name: deny-all 
      action: reject
bgp:
  local_as: 65102
  groups:
    - name: IBGP
      type: internal
      desc: "IBGP BGP Peering"
      hold: 30
      policies:
        export:
          - internal_networks 
          - deny-all
        import: 
          - internal_networks
          - external_networks
          - default_route
          - deny-all
      neighbours:
        - peer: 192.0.2.102
          loc_ip: 192.0.2.202
          desc: "junos-02 IPv4"
        - peer: "2001:db8:902:beef::1"
          loc_ip: "2001:db8:902:beef::2"
          desc: "junos-02 IPv6"
zones:
  - name: "internal"
    host_traffic:
      protocols:
        - bgp
        - ospf
        - ospf3
      services:
        - ping
        - traceroute
interfaces:
  - junos_if: "fxp0"
    unit: 0
    desc: "Management"
    enabled: "true"
    ipv4_addr: "10.15.30.34/24"
  - junos_if: "ge-0/0/0"
    unit: 0
    desc: "VLAN Bridge"
    enabled: "true"
    subint:
      vlans:
      - 202
  - junos_if: "ge-0/0/0"
    unit: 202
    desc: "To junos-01"
    enabled: "true"
    ipv4_addr: "10.100.202.253/24"
    ipv6_addr: "2001:db8:202::f/64"
    if_zone: "internal"
    ospf:
      area: "0.0.0.0"
    ospfv3:
      area: "0.0.0.0"
  - junos_if: "lo0"
    unit: 0
    desc: "Loopback"
    enabled: "true"
    if_zone: "internal"
    ipv4_addr: "192.0.2.202/32"
    ipv6_addr: "2001:db8:902:beef::2/128"
    ospf:
      area: "0.0.0.0"
      passive: true
    ospfv3:
      area: "0.0.0.0"
      passive: true

There are significantly more variables created for this JunOS lab than for the Cisco IOS lab. However, we did not apply any routing policies (i.e. route-maps and prefix lists) on Cisco IOS. Also we are applying firewall policies on JunOS, which are more complex than access-lists.

If you integrate these tasks with something like Netbox to supply your variables, you can build a huge estate all from a single source of truth, with little to no manual configuration involved.

Running the playbooks

Below is an Asciinema output of my terminal when running the playbooks, so you can see them being applied: -

As in the IOS lab, we see a lot of tasks marked as “changed”, despite no changes actually taking place.

This is an artifact of using the junos_config module so extensively. Also, because in some cases we are applying the delete option to remove existing configuration, it will mark a change . The delete word does not appear in the device configuration itself, so it will always be seen as a difference (and hence a change).

Again, using something like changed_when could tidy this up significantly.

Native modules versus junos_config

Below is a summary of how many different modules are used, and also how many in total were native modules (compared to using junos_config).

Module Used
junos_config 20
junos_l3_interfaces 2
junos_banner 2
junos_system 1
junos_logging 1
junos_interfaces 1

As in the IOS lab, the junos_config module is used more than any other module. Also no native modules exist for any routing protocol. While we do still make use of some native Ansible features (like when and loop), most of our conditional logic and complexity has to be templated.

However, fewer steps are required to configure a device. Because of this, the time to configure both routers is around half of what it is in the Cisco IOS lab (4 minutes for IOS, 2 minutes for JunOS).

Thoughts compared to IOS

If I compare the experience of using IOS and JunOS (generally, rather than when using Ansible), JunOS would be my preference. The commit system, the logical grouping of configuration, and sane defaults (e.g. all BGP peers must belong to a group) all contribute to a smooth experience in using JunOS day to day.

However when managing them with Ansible, IOS is currently the more mature option, given the amount of native modules.

It is quite possible to configure a BGP-speaking IOS router without much/any templating, whereas with JunOS you need to template most configuration beyond basic interfaces and system settings.

If you are already familiar with JunOS though, managing the devices with Ansible can save time on future deployments and repeatable tasks. You also automatically benefit from more consistent configuration, with configuration being templated rather than hand-crafted. Once you have your templates configured, you can quickly add new devices by creating a host_vars file and an entry in your inventory, or relying on some form of dynamic inventory/API to retrieve variables from.

Summary

Despite some setbacks, and the need to create a lot of templates during this lab, I have still been impressed by what is available to automate deployments for network engineers now. The networking ecosystem for Ansible is growing considerably. Over time I expect most vendors to have modules that cater to most common use cases (i.e. firewalling, routing protocols).

Creating the initial templates has taken more time than it would with IOS, but at the same time it does allow some flexibility that is not always available, or planned to be, within native modules. A good example would be using the apply-path statements to dynamically generate prefix-lists for IPv4 and IPv6.

For those using JunOS, I recommend trying out Ansible to manage your devices. The benefits in terms of configuration consistency and repeatable deployments are worth it alone!

The final configs from the firewalls are in my Network Automation with Ansible repository. The next part of this series will be configuring Arista devices, running EOS.