Vendor Migrations: Automating Config Translations
I was recently pulled in to support a migration project, where some H3C firewalls were being swapped out for Cisco ASAs. While the base config was easy enough to replicate, recreating the thousands of Access Control List (ACL) rules in Cisco ASA syntax looked to be an incredibly daunting manual task.
In this post I will walk through the steps I took to go about automating this task, and how approach similar challenges
The Task
Here is an idea of the kind of ACL configuration I was tasked with translating:
Advanced ACL 3001, named -none-, 26 rules,
EXAMPLE1
ACL's step is 5
rule 0 permit tcp source X.X.X.X 0.0.3.255 destination Y.Y.Y.Y 0 destination-port eq www
rule 5 permit tcp source X.X.X.X 0.0.3.255 destination Y.Y.Y.Y 0 destination-port eq 443
rule 5 permit tcp source X.X.X.X 0.0.3.255 destination Y.Y.Y.Y 0 source-port eq 10002
...
Advanced ACL 3002, named -none-, 1 rule,
EXAMPLE2
ACL's step is 5
rule 0 permit ip source Y.Y.Y.Y 0.0.0.255
Advanced ACL 3003, named -none-, 78 rules,
EXAMPLE3
ACL's step is 5
rule 0 deny ip source X.X.X.X 0.0.255.255 destination Y.Y.Y.Y 0.0.0.255
rule 5 permit ip source X.X.X.X 0.0.255.255 destination Y.Y.Y.Y 0.0.0.255
rule 10 permit udp source X.X.X.X 0.0.255.255 destination-port eq dns
...
This is a very simple syntax, where every rule is configured globally using inline IP addressing and services. We have a variety of different rules here, some including source ports, others with destination ports, some explicitly permitting only traffic from a specific source to specific destination network.
Comparing this to Cisco ASA config for a single Extended ACL rule, we start to get an idea of just how different these syntaxes are:
Single H3C Rule:
rule 0 permit tcp source X.X.X.X 0.0.0.255 destination Y.Y.Y.Y 0.0.0.255 destination-port eq www
Single Cisco ASA Rule (with object-groups):
object network SOURCE_OBJECT
subnet X.X.X.X 255.255.255.0
object network DEST_OBJECT
subnet Y.Y.Y.Y 255.255.255.0
object-group network SOURCE_GROUP
network-object object SOURCE_OBJECT
object-group network DEST_GROUP
network-object object DEST_OBJECT
object service SERVICE_OBJECT
service tcp destination eq 443
object-group service SERVICE_GROUP
service-object object SERVICE_OBJECT
access-list XXX extended permit object-group SERVICE_GROUP object-group SOURCE_GROUP object-group DEST_GROUP log disable
Now this level of object and object-group nesting is optional on an ASA for most rules. Inline IP addressing is supported for the source and destination networks, however source and destination ports are NOT supported inline (can only permit L4 protocols such as TCP or UDP on any port), and so the service objects were at least necessary.
As we were dealing with thousands of service-specific ACL rules we wanted to take advantage of the ASA's inherit nested configuration structure as much as possible, and so that was my aim.
The Source Data
In order to make this task programmable, the source data needed to be formatted properly. This was always going to be messy, but the variables needed to be pulled out of the huge dump of ACL rules somehow.
The structure of many network device configuration sections lends themselves very well to CSV, a tabular format where each row is it's own entity with a selection of attributes. CSV does not maintain a relationship structure between items like XML or JSON, but for ACL rules I could likely get away without this.
With some elbow grease and a lot of VIM macros, I eventually formatted the ACL rules as CSV and included some arbitrary data (such as subnet mask CIDR notation) to help me later on:
From this I can use a bit of Python to convert the CSV to YAML:
#!/usr/bin/env python
import csv
import sys
import yaml
csv_data = []
with open(sys.argv[1]) as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
csv_data.append(row)
with open(sys.argv[1] + '.yml', 'w') as outfile:
outfile.write(yaml.dump({'csv_data': csv_data}))
And load into Ansible natively:
vars_files:
- vars/vars.csv.yaml
My next challenge was to identify every variation of ACL rule in my source data so that I could identify each variation and appropriately template. After combing through the rules for some time, I managed to pull out the following variations:
Although the thousands of ACL rules looked daunting, now I only have 12 different rule variations to template. Easy!
Moving on...
The Template
Now that I have properly formatted source data and my rule variations split out, I went on to create a Jinja2 template. I began by writing the logic to identify the rule types:
{% for item in csv_data %}
{# SOURCE ONLY #}
{% if item.src_net|length and
not item.dst_net|length and
not item.src_prt|length and
not item.dst_prt|length and
item.encryption_domain == '' %}
{# DEST ONLY #}
{% elif not item.src_net|length and
item.dst_net|length and
not item.src_prt|length and
not item.dst_prt|length and
item.encryption_domain == '' %}
{# SOURCE AND DEST ONLY #}
{% elif item.src_net|length and
item.dst_net|length and
not item.src_prt|length and
not item.dst_prt|length and
item.encryption_domain == '' %}
{# SOURCE AND DEST WITH SOURCE PORT #}
{% elif item.src_net|length and
item.dst_net|length and
item.src_prt|length and
not item.dst_prt|length and
item.encryption_domain == '' %}
{# SOURCE WITH DEST PORT #}
{% elif item.src_net|length and
not item.dst_net|length and
not item.src_prt|length and
item.dst_prt|length and
item.encryption_domain == '' %}
{# DEST WITH DEST PORT #}
{% elif not item.src_net|length and
item.dst_net|length and
not item.src_prt|length and
item.dst_prt|length and
item.encryption_domain == '' %}
{# SOURCE AND DEST WITH DEST PORT #}
{% elif item.src_net|length and
item.dst_net|length and
not item.src_prt|length and
item.dst_prt|length and
item.encryption_domain == '' %}
{# SOURCE ONLY ENCRYPTION DOMAIN #}
{% elif not item.dst_net|length and
item.src_net|length and
not item.dst_prt|length and
not item.src_prt|length and
not item.customer|length and
item.encryption_domain == 'yes' %}
{# DEST ONLY ENCRYPTION DOMAIN #}
{% elif item.dst_net|length and
not item.src_net|length and
not item.dst_prt|length and
not item.src_prt|length and
not item.customer|length and
item.encryption_domain == 'yes' %}
{# SOURCE AND DEST ENCRYPTION DOMAIN #}
{% elif item.dst_net|length and
item.src_net|length and
not item.dst_prt|length and
not item.src_prt|length and
not item.customer|length and
{# OTHER (DEBUG) #}
{% else %}
FAILED TO MATCH RULE TEMPLATE
!!!! Original rule: {{ item }}
{% endif %}
{% endfor %}
Next, creating network objects and object-groups:
object network {{ item.src_net }}-{{ item.src_cidr }}
{% if item.src_mask == "255.255.255.255" %}
host {{ item.src_net }}
{% else %}
subnet {{ item.src_net }} {{ item.src_mask }}
{% endif %}
object network {{ item.dst_net }}-{{ item.dst_cidr }}
{% if item.dst_mask == "255.255.255.255" %}
host {{ item.dst_net }}
{% else %}
subnet {{ item.dst_net }} {{ item.dst_mask }}
{% endif %}
object-group network {{ item.acl_id }}-SOURCE
network-object object {{ item.src_net }}-{{ item.src_cidr }}
object-group network {{ item.acl_id }}-DESTINATION
network-object object {{ item.dst_net }}-{{ item.dst_cidr }}
This renders into the following for each rule:
object network 1.1.1.1-32
host 1.1.1.1
object network 123.123.123.0-24
subnet 123.123.123.0 255.255.255.0
object-group network EXAMPLE-SERVICE-SOURCE
network-object object 1.1.1.1-32
object-group network EXAMPLE-SERVICE-DESTINATION
network-object object 123.123.123.0-24
Now this is where it is important to think ahead. Not all of my rule types required object-group nesting, and it wouldn't be wise to apply to all. If a certain service were to have a range of source networks AND a range of destination networks, by grouping all sources into a group and all destinations into a group I would be opening up the firewall to permit traffic from sources from some rules to destinations of other rules, which could be a critical failure.
Fortunately for me, a lot of these services in my example were standardised with all the sources the same. For others, I chose to explicitly separate out the template in the Jinja2 template ( {%elif item.service == 'XXX' %} ) to make a bespoke variation.
Next, services:
object service {{ item.transport }}-{{ item.dst_prt }}
service {{ item.transport }} destination eq {{ item.dst_prt }}
object-group service EXAMPLE-SERVICE-GROUP
service-object object {{ item.transport }}-{{ item.dst_prt }}
This will render into the following:
object service tcp-dns
service tcp destination eq dns
object-group service EXAMPLE-SERVICE-1-SERVICE-GROUP
service-object object tcp-dns
---
object service udp-10101
service udp destination eq 10101
object-group service EXAMPLE-SERVICE-1-SERVICE-GROUP
service-object object udp-10101
Similarly to the network object-groups, service grouping will not be appropriate for all ACL types (to avoid accidentally opening up ports for networks that don't require them). Think ahead.
Finally, access-lists:
access-list {{ item.acl_id }} extended permit {{ item.transport }} object {{ item.acl_id }}-SOURCE object-group {{ item.acl_id }}-DESTINATION log disable
This of course varied between my rule types, depending on whether they used objects or object-groups. Alternatively, I made some exceptions using inline addressing like the following:
access-list {{ item.acl_id }} extended permit {{ item.transport }} {{ item.src_net }} {{ item.src_mask }} {{ item.dst_net }} {{ item.dst_mask }} log disable
Finally, my .j2 file was complete:
The Automation
With the .j2 file complete, I can use Ansible to tie it all together:
- hosts: localhost
connection: local
tasks:
- name: Parse CSV and make vars file
command: "python csv_to_yaml.py vars/oneofeach.csv"
- hosts: localhost
connection: local
vars_files:
- vars/vars.csv.yml
tasks:
- name: Generate Object Config
template:
src: "{{ item.src }}"
dest: "{{ item.dst }}"
mode: 0777
with_items:
- {src: 'templates/full.j2',dst: 'output/full.config'}
And then running the playbook:
I get my final rendered configuration like the below:
If you have made it this far then thanks for reading and good luck on your quest!
Skylar