How to enforce security compliance on a network with Ansible. The (almost) perfect recipe.
Let’s keep it nice and short because if you are reading this post, probably you are looking for a practical solution and you do not want to wast time reading about how Ansible is cool or to use this module or that filter. So, let’s dig straight into it.
(Short) Intro.
Let’s assume you have 400 routers across your network. Different hardware and software version. Now, let’s assume that InfoSec guys knock on your office and asking you to enforce some sort of security compliance for all management configuration on those 400 devices (NTP, SNMP, AAA, etc.) and making sure that, even if someone will change it intentionally or not (for example, removing a valid SNMP server IP or introducing a bogus) the config will be restored.
Since life is never easy, let’s assume you cannot do config-replace so you cannot build your NaC — perhaps the devices do not support atomic configuration changes or (most probably) your configs are so messy that build a config template will drive you crazy.
So, how can we do that? Easy: look what a network engineer would manually do it and replicated it with Ansible.
Mimic human behavior.
Let’s pretend we are the lucky guy that has to apply the new config on those 400 devices and make sure that it will not be changed in the future. For this example let’s be focus on NTP servers IP (by the way, this approach is pretty much valid for everything, not just for NTP or management configuration)
- Log into device and check the hardware version and OS version to figure out config syntax.
- Find the NTP config lines of our interest
- Find the IP addresses into those config lines
- Compare the IP addresses into the running-config with those that we need to configure
- Delete running-config lines with unwanted NTP servers IP
- Add new config lines with new NTP server IPs
- Make sure the new NTP is in synch
- Repeat the step the day after and remove bogus lines if necessary and/or add the legal ones if missing.
Before to “translate” that workflow in Ansible, let’s brake those steps in 4 groups that we will call them: get_facts
, pre_config_change
, config_change
, post_config_check
. I will let you figure out which step goes in which group ;)
The Ansible bit.
Let’s assume that we already got our device facts with some xos_facts
module and we saved our OS version in a variable called (…guess how?) os_version
(for this example we will use CISCO IOS platforms with OS version ≤ 12.0 or ≥ 15.0 )
If we follow the previous workflow, we now need to find the running-config lines of our interest. In this case, those lines containing NTP servers IP:
- name: PRE-CONFIG-CHANGE | NTP | find NTP servers config lines.
ios_command:
commands:
- show run | i ntp server
register: run_ntp_server
tags:
- pre_confg_change
- ntp- name: PRE-CONFIG-CHANGE | NTP | find current NTP servers
set_fact:
ntp_list: "{{ run_ntp_server.stdout_lines[0] }}"
tags:
- pre_confg_change
- ntp
We now create an empty list where we append just the NTP IPs from the above output:
- name: PRE-CONFIG-CHANGE | NTP | set empty list
set_fact:
current_ntp_servers: []
tags:
- pre_confg_change
- ntp- name: PRE-CONFIG-CHANGE | NTP | build list with running conf IPs
set_fact:
current_ntp_servers: "{{ current_ntp_servers }} + [ '{{ item | ip_filter }}' ]"
loop: "{{ ntp_list }}"
tags:
- pre_confg_change
- ntp
We can now move to the next step where we remove unwanted IP NTP server config lines. We also include a task called assert.yml
, this will help us to enforce idempotency in case the OS will accept our config line but he will change the way it is displayed in the running-config as well as make sure all the steps are done properly (is basically an empty list where we append the changed
status of each single task).
Our legal IPs are under group_vars/all/ntp.yml
in dictionary format:
ntp:
servers:
- 10.75.32.5
- 10.75.33.5
Here the tasks where we remove the line ONLY if the IP adresses founded previously are not the same as those into our variables:
- name: CONFIG-CHANGE | NTP | remove legacy NTP servers if any
ios_ntp:
server: "{{ item }}"
state: absent
loop: "{{ current_ntp_servers }}"
when: item not in ntp.servers
register: result
notify: save config
tags:
- config_change
- ntp- include_tasks: assert.yml
tags:
- config_change
- ntp
In this case, the ios_ntp
module is smart enought to realize if you are working with IOS version ≤ 12.0 or ≥ 15.0 and pushing the right config lines, but in a case where this functionality is not supported or we need to implement more complex logic, we could add a simple conditional based on os_version
fact. Here an example for NTP ACL:
- name: CONFIG-CHANGE | NTP | set NTP ACL name IOS ver 15.
ios_ntp:
acl: NTP_ACL
register: result
when: "'15.' in os_version"
notify: save config
tags:
- config_change
- ntp- include_tasks: assert.yml
tags:
- config_change
- ntp- name: CONFIG-CHANGE | NTP | set NTP ACL name IOS ver 12.
ios_ntp:
acl: 30
register: result
when: "'12.' in os_version"
notify: save config
tags:
- config_change
- ntp- include_tasks: assert.yml
tags:
- config_change
- ntp
Let’s now validate our NTP configuration and make sure that our playbook is idemponenet in case we will run a second time (second_run
is a variable defined in AWX workflow — more will follow)
- name: POST-CONFIG-CHANGE | NTP | check assertion idempotency
assert:
that:
- "{{ item }} == false"
fail_msg: "One or more task is not idempotent for this router"
success_msg: "All good!"
loop: "{{ assertion_list }}"
when:
- second_run is defined
- second_run == true
tags:
- config_change
- ntp- name: POST-CONFIG-CHANGE | NTP | check NTP association
ios_command:
commands:
- show ntp association
register: ntp_association
tags:
- post_change_checks
- ntp- name: POST-CONFIG-CHANGE | NTP | fail if NTPs are out of sync
fail:
msg: "NTP not in synch"
when:
- "'.INIT.' in ntp_association['stdout'][0]
- '0.0.0.0' in ntp_association['stdout'][0]"
tags:
- post_change_checks
- ntp
The workflow in AWX (Ansible Tower)
In order to enforce our configuration on our network, is good to integrate our playbook into a CI/CD pipeline so we can push quickly new changes every time InfoSec requires. Is also good to hook CI/CD to AWX API so we can have support from a nice and intuitive UI as well as set-up daily cronjobs. Our complete workflow, will look somethingh like this:
Final considerations.
Nothing really special in this post but many times I have seen people knowing insideout Ansible modules but often struggling to build a proper workflow and implement more advanced loigc and checks.
“Teach the method, not the tools” [cit.]