77 minutes
Ansible for Networking - Part 4: Juniper JunOS
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: -
- Part 1 - Start of the series
- Part 2 - The Lab Environment
- Part 3 - Cisco IOS
- Part 5 - Arista EOS
- Part 6 - MikroTik RouterOS
- Part 7 - VyOS
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
- edge router -
- IPv4 Subnet on VLAN202: 10.100.202.0/24
- edge router -
10.100.202.254/24
- internal router -
10.100.202.253/24
- edge router -
- IPv6 Subnet on VLAN102:
2001:db8:102::/64
- edge router -
2001:db8:102::f/64
- netsvr-01 -
2001:db8:102:ffff/64
- edge router -
- IPv6 Subnet on VLAN202: 2001:db8:202::/64
- edge router -
2001:db8:202::a/64
- internal router -
2001:db8:202:f/64
- edge router -
- IPv4 Loopback Addressing
- edge router -
192.0.2.102/32
- internal router -
192.0.2.202/32
- edge router -
- IPv6 Loopback Address
- edge router -
2001:db8:902:beef::1/128
- internal router -
2001:db8:902:beef::2/128
- edge router -
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-set
s. 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 unlesscommit
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 tasksfirewall
- Apply before routing so that the device is not open to the world when publicly routablerouting
- Routing is required for NAT and AAA to functionsnmp
- No dependency on any service, so this can go anywherenat
- Apply this after routing, otherwise the internal router has no default route to reach external destinations anywayaaa
- 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.
devops sysadmin ansible config management networking
technical sysadmin config management networking
16225 Words
2020-04-04 23:30 +0000