Automatically Configure switchports for 802.1x, based on assigned VLANs, by using Ansible
How-To: Ansible-Cisco
January 4, 2023
The Scenario
Depending on where you are in the rollout of an 802.1x implementation, some switchports may be configured for 802.1x authentication, while others may not. For example, maybe you have a phased implementation where you focus on the low-hanging fruit first, like user machines and printers. Then you gradually implement more access control.
Also, some ports might need to be configured differently. For example, client ports may use host-mode multi-domain, while access points uses host-mode multi-host.
! Client Closed Mode
interface GigabitEthernetX/X/X
no logging event link-status
switchport mode access
switchport voice vlan X
access-session host-mode multi-domain
source template dot1x_closed
! AP Closed Mode
interface GigabitEthernetX/X/X
switchport mode access
access-session host-mode multi-host
source template dot1x_closed
Note: While you could dynamically assign an interface template to a port via ISE, based on the type of client connected, some parameters are not configurable inside a template. access-session host mode is one of the commands that can’t be applied to an interface template.
Challenges
We see the usual challenges here, if we were to configure every port manually:
Larger deployments takes longer time to implement. You might forget a switch
We have to verify the VLAN assignment of every port on every switch. High risk for error, especially if the VLAN assignments are not in any consecutive order.
The Goal
The Goal is to make a playbook that automatically configures the switchports based on the VLAN assignment. I assume the reader have some experience working with ansible from before.
Automate switchport configuration using Ansible
Step 1: Gather the necessary information
Note: I’m running Ansible version 2.9 and Python version 3.6
First we need a list of all the switchports, excluding the uplink ports (they are not interesting for 802.1x configuration). A basic show running-config with a regex filter will do.
When we have the list of all the switchports, we can use that to check the VLAN assignment of those ports. If the VLAN number is the same on every switch in your environment, you can just use the information from the running-configuration. If your environment uses different VLAN numbers with the same name on different locations, you can use the name from the show interface switchport command.
Example from a Catalyst 9300:
! Lists all regular switchports, even when stacked
Switch#show running-config | i interface GigabitEthernet./0/
interface GigabitEthernet1/0/1
interface GigabitEthernet1/0/2
interface GigabitEthernet1/0/3
interface GigabitEthernet1/0/4
interface GigabitEthernet1/0/5
interface GigabitEthernet1/0/6
interface GigabitEthernet1/0/7
interface GigabitEthernet1/0/8
interface GigabitEthernet1/0/9
...
! Only Check for 802.1x-ready VLANS.
Switch#show interface Gi1/0/29 switchport | i \(native\)|\(CLIENT\)|\(AP\)
Access Mode VLAN: 2 (native)
Now we can write the first part of our playbook that will gather this information for us.
# Gather some facts. This might be useful for later.
- name: Gather Subset of facts
ios_facts:
gather_subset:
- min
- name: Gather necessary information from the switches
block:
- name: Gather information about available switchports
ios_command:
commands:
- show running-config | i interface GigabitEthernet./0/
register: Switchports
- name: Gather information about configured vlans
ios_command:
commands:
- show {{ item }} switchport | i \(native\)|\(CLIENT\)|\(AP\)
loop: "{{ Switchports.stdout_lines[0] }}"
register: Vlans
Note: Only the tasks are included in these examples.
Note: block is optional. There are many different reasons to use blocks. In this case it’s just to make the playbook look nice and ordered.
Step 2: Create lists
This was the hardest part to figure out. Right now we have 2 separate lists that needs to be combined. Also, interfaces with no 802.1x-ready VLANs needs to be omitted.
In order to combine 2 lists, we can create a dictionary, where the interface IDs are the key attributes, and the VLANs are the value attributes of those keys. In the output from the show interface switchport command, you can see that there is a colon (:) inside the output. That must be removed for this to work.
# Create two lists, one of the interface ID's and one of the switchport VLANs. remove any ":" from the output.
- set_fact:
Vlans_list: "{{ Vlans.results|map(attribute='stdout_lines')|flatten|replace(':', '') }}"
Switchports_list: "{{ Vlans.results|map(attribute='item')|flatten|replace(':', '') }}"
# Create a dictionary where the interface ID's are the key attributes, and the vlans are the value.
- set_fact:
New_list: "{{ dict(item.Switchports | zip(item.Vlans)) }}"
loop:
- { Switchports: "{{ Switchports_list }}", Vlans: "{{ Vlans_list }}" }
Note: The filters inside the curly brackets, after the |, have to be in the correct order. They are interpreted from left to right. Ansible documentation about filters: https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_filters.html
Step 3: Create a filtered list
As I learnt the hard way, dictionaries are different than lists, so regular expression filters don’t seem to work so well for those. However, when you know what you are dealing with, you can match either the key or the value from the dictionary by converting it to a list, then convert it back to a dictionary.
# The New_list dictionary get's converted into a list, where the values can be matched. Then the matched values get's converted back to a dictionary.
# Filter only dot1x-ready ports (CLIENT, native and AP) from New_list
- set_fact:
Client_ports: "{{ New_list | dict2items | selectattr('value', 'match', '.*CLIENT.*|.*native.*') | list | items2dict | flatten }}"
AP_ports: "{{ New_list | dict2items | selectattr('value', 'match', '.*AP.*') | list | items2dict | flatten }}"
Note: dict2items converts the dictionary onto an itemized list, where the values can be matched. items2dict converts it back to a dictionary and the flatten filter at the end converts the dictionary back to a regular list with only the filtered interface IDs in it.
Note: to see how the itemized list looks like you could make a debug task that prints the variable with the dict2items filter applied.
Step 4: Configure the Switchports
The hardest part is already done. Now we just need to create ios_config tasks that will automatically configure the ports for us.
- name: Configure dot1x on the 802.1x-ready switchports
block:
- name: Configure Client Ports for 802.1x/MAB
ios_config:
lines:
- shutdown
- no logging event link-status
- switchport mode access
- switchport voice vlan {{ voice_vlan }}
- access-session host-mode multi-domain
- source template dot1x_closed
- no shutdown
parents:
- "{{ item }}"
match: line
diff_against: startup
save_when: modified
with_items:
- "{{ Client_ports }}"
when: Client_ports != "" # If the switch doesn't have any client ports, skip this task.
- name: Configure AP Ports for 802.1x/MAB
ios_config:
lines:
- shutdown
- logging event link-status
- switchport mode access
- access-session host-mode multi-host
- source template dot1x_closed
- no shutdown
parents:
- "{{ item }}"
match: line
diff_against: startup
save_when: modified
with_items:
- "{{ AP_ports }}"
when: AP_ports != "" # If the switch doesn't have any AP ports, skip this task.
Note: All through this playbook I have been using the loop function until now, because ansible is recommending that. But for some reason, the module ios_config didn’t work with loop, so I had to make an exception here and use with_items instead.
Special Mentions
Thank you Konstantin Suvorov. I don’t know you, but I see your name on stackoverflow all the time when I’m researching. If our paths crosses one day, I want to buy you a beer.