Terraform for Network Engineers: Part Three

Streamline Palo Alto firewall setups with a custom Terraform module for easy security policy configs!

Terraform for Network Engineers: Part Three
Terraform for Network Engineers - Part Three

If you have not read the previous parts of this series, I recommend you start there.

Welcome back to our journey of exploring Terraform for Network Engineers. In the previous part, we left ourself with a few challenges network engineers face when diving into the world of Terraform. Let's quickly recap those challenges:

  1. Setup Complexity: Are we really expecting network engineers to set up a Terraform project and write HCL code for creating resources on Panorama?
  2. Documentation Dive: Are network engineers supposed to dig into Terraform provider documentation to configure their desired resources?
  3. State File Management: What do we do with the state file? How do we manage it and share it with the team? What if it gets corrupted?

In this part, we'll tackle the first two challenges. We will explore how we can simplify the configuration file and abstract the complexity of the Terraform provider documentation.

Before we dive in, lets decompose the components of a simple Palo Alto Networks security policy configuration. A simple policy is composed of the following components:

  1. Device Group
  2. Source and Destination Zones
  3. Source and Destination Addresses
  4. Services or Destination Ports
  5. Application Filters
  6. Action - In our case, we will set it to `allow`.

Now that we have a basic understanding of the components, a possible solution to simplify the configuration file is to create an abstraction that allows network engineers to configure the security policy using a simple configuration file. This abstraction will take care of the complexity of the provider documentation and provide a simple interface to configure the security policy.

A version of the simplified configuration file may look like this:

{
  device_group = "DEMO"
  source_zone = ["INSIDE"]
  destination_zone = ["DMZ"]
  rules = [
    {    
      name        = "POLICY NAME"
      source      = ["any"]
      destination = ["10.10.10.1/32", "10.10.10.2/32"]
      services    = ["tcp-80", "tcp-443", "tcp-8080"]
      application = ["application-default"]
    }
  ]
}

Simplified configuration block

Here we have a list of rules to configure for a zone pair. Let us make the following assumptions:

  1. We will be treating the source and destination as a list of IP addresses and will be used as is in the configuration.
  2. The services will follow the format protocol-port. (Port ranges are not supported currently)
  3. The application will be a list of applications as recognised by Palo Alto Network Firewall.

The solution to achieve this abstraction is to create a custom module.

Terraform Custom Module

Quoting from HashiCorp's documentation

A module is a container for multiple resources that are used together. You can use modules to create lightweight abstractions, so that you can describe your infrastructure in terms of its architecture, rather than directly in terms of physical objects.

Modules are defined using all of the same configuration language concepts that we have already seen. This means that modules can include resources, input variables, output values, and even other child modules.

Module Setup

Let's start by creating a new directory called palo-module and creating a few configuration files that we will use to define the module.

mkdir palo-module && cd palo-module
touch providers.tf
touch variables.tf
touch locals.tf
touch main.tf

Module Setup

Providers.tf

For the module, we are going to the Palo Alto Networks provider. We will define the provider in the providers.tf file.

# Define the required providers
terraform {
  required_providers {
    panos = {
      source  = "PaloAltoNetworks/panos"
      version = "1.11.1"
    }
  }
}

providers.tf

Variables.tf

Next, we will define the input variables required for the module. The variables will be used to configure the security policy on the Palo Alto Networks firewall. Here we will define the variables required to configure the resource based on the components we discussed earlier.

variable "device_group" {
  description = "The device group to which the security policy will be applied"
  type        = string
}

variable "source_zone" {
  description = "The source zone for the security policy"
  type        = list
}

variable "destination_zone" {
  description = "The destination zone for the security policy"
  type        = list
}

variable "rules" {
  description = "The port rules for the security policy"
  type        = list
}

Variables.tf

Locals.tf

The locals.tf file is used to define local variables and expressions that can be used throughout the module. It is also a good place to perform any transformations on the input variables.
In our case, we will loop over each rule and each service in a rule to create a list of services that needs to be configured on the Palo Alto Networks Panorama.

locals {
  # Create a list of dictionaries with rule names and services.
  all_services = flatten([
    for rule in var.rules : [
      for service in rule.services : {
        rule_name = rule.name
        service   = service
      } if service != "any" && service != "application-default"
    ]
  ])
}

Locals.tf

Main.tf

Finally, we will define the resources required to configure the security policy on the Palo Alto Networks firewall.
First, we will loop over the list of services that we computed in the locals.tf file which we determined are required for the security policies and create a service object for each service.Second, we will loop over the list of rules and create a security policy for each rule.

# loop through the list of services that we need for all rules and create a service object for each
# this list was computed in locals.tf
# the name used for each service will be rule_name-service
# since we have assumed that the service will follow the format protocol-port, we can split the service string to get 
# the protocol and port
resource "panos_panorama_service_object" "objects" {
  for_each = {
    for service in local.all_services : "${service.rule_name}-${service.service}" => service
  }
  name             = each.key
  protocol         = element(split("-", each.value.service), 0)
  destination_port = element(split("-", each.value.service), 1)
}

# loop through the list of rules and create a security rule for each
resource "panos_security_rule_group" "rules" {
  for_each     = { for rule in var.rules : rule.name => rule }
  device_group = var.device_group
  rulebase     = "pre-rulebase"
  rule {
    name                  = each.value.name
    source_zones          = var.source_zone
    destination_zones     = var.destination_zone
    categories            = ["any"]
    source_users          = ["any"]
    source_addresses      = each.value.source
    destination_addresses = each.value.destination
    services = (each.value.services == tolist(["application-default"]) ? ["application-default"] :
      each.value.services == tolist(["any"]) ? ["any"] :
    [for service in each.value.services : panos_panorama_service_object.objects["${each.value.name}-${service}"].name])
    applications = each.value.application
  }
  depends_on = [panos_panorama_service_object.objects]
}

main.tf

In the above code, most of the configuration is self-explanatory. The only parts that may need some explanation is the services attribute in the panos_security_rule_group resource. So, lets break that down.

It is a conditional statement that checks if the services is set to ["application-default"] if yes, we set the service to ["application_default"] if not, it next evaluates if services is set to ["any"] if yes, we set the services to ["any"] if it is neither, we loop over each service in the rule and get the service object name from the panos_panorama_service_object resource.

The way to read the conditional statement is as follows:

<variable> = <condition> ? <true_value> : <false_value>

In addition to the condition, panos_panorama_service_object.objects["${each.value.name}-${service}"].name also requires some explanation. This is a way to reference the service object that we created using the panos_panorama_service_object resource earlier. The name of the service object is a combination of the rule name and the service name. This is how we can reference the service object for each service in the rule. This becomes more evident when you look at the state file.

πŸ’‘
When I first started using Terraform, I inherited module code from a colleague. The toughest part was understanding complex expressions like the one mentioned above. ChatGPT does a good job of explaining these expressions, but it often falls short when it comes to generating expressions based on specific inputs.

Module Usage

Now that we have created a custom module which abstracts the complexity, lets see how we can use this module in our palo configuration file.

At this point, my directly structure looks like this:

.
β”œβ”€β”€  palo-module
β”‚  β”œβ”€β”€  locals.tf
β”‚  β”œβ”€β”€  main.tf
β”‚  β”œβ”€β”€  providers.tf
β”‚  └──  variables.tf
└──  palo.tf

Project Directory structure

Here we have defined a list of rules that we want to configure on Panorama between the source zone Inside and the destination zone DMZ. We are also souring the module from the local directory.

# We are sourcing the module configuration from one directory up
module "paloalto_security_policy" {
  source           = "./palo-module"
  source_zone      = ["Inside"]
  destination_zone = ["DMZ"]
  device_group     = "DEMO"
  rules = [
    {
      name        = "rule-1"
      source      = ["any"]
      destination = ["10.10.10.1/32", "10.10.10.2/32"]
      services    = ["tcp-80", "tcp-443", "tcp-8080"]
      application = ["any"]
    },
    {
      name        = "rule-2"
      source      = ["any"]
      destination = ["10.10.10.10/32"]
      services    = ["application-default"]
      application = ["any"]
    }
  ]
}

palo.tf

Let's run through the terraform workflow to see if our module works as expected.

terraform init
terraform plan -out=plan

Looking over the plan, it is creating three service objects and two security rules. Exactly what we expected. Here I am writing the plan to a file called plan and applying it. This prevents Terraform from re-evaluating the plan during the apply stage.

terraform apply plan

At this stage if you check the Panorama, you should see the security rules configured as expected.
To further make the module a re-usable block, we can store the module in a git repository and source the module configuration from the remote source.

Wrapping up

In this part, we demonstrated how to create a custom Terraform module that simplifies the configuration of security policies on a Palo Alto Networks firewall. By abstracting the complexity of the provider documentation, our module offers an easier interface for users. We also showed how to integrate this module into a configuration file to effectively manage security policies.

For the purpose of the Demo, I have only abstracted the creation of Service Objects, there is more abstraction that can be done. Some of the possibilities are

  1. Creating of various other objects (network, network group, service groups...)
  2. Auto map port to application.
  3. Ability to override the ports for a application.
  4. Ability to override the default mapped application for a port

etc...

In the following parts, I will address the elephant in the room - state file management. We will explore how to manage the state file and share it with the team.

All the code we looked at in this article can be found in the repository below.