Vendor Migrations: Automating Config Translations

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

Related Article