The following Ansible playbook is how I manage firewall rules on a Palo Alto firewall. My overall playbook methodology is to be able to reuse playbook task lists as though they were building blocks. Also, to be able to both add and remove configuration using the same playbook. To do this, a common trick I like to use is the CLI flag “-e” to specify an input file. The input file is where the abstracted configuration is defined and how I tell the playbook what to build.

Depending on the resources of the company most ticketing systems, like Service Now or CA Service Desk, can output the proper YAML input file after all certain workflow items have been approved. The ticketing system can then output to a Samba Share, that has a crontab to kick off and ingest any new input files or the ticketing system itself can kick off the playbook directly if you have a Ansible Tower or AWX in the environment.

The following is my input. When all is said and done, I put most of my mental effort on how best to structure the input. Ideally I try to ask for as little as possible and try to make it so it can be adapted to any vendor product, such as a Cisco FMC.

ER/CO99999.yaml: ASCII text: ASCII text

---
ticket: CO99999
security_rule:
- description: Ansible test rule 0
  source_ip:
  - 192.168.0.100
  - 192.168.100.96
  destination_ip:
  - any
  service:
  - tcp_9000
- description: Another Ansible test rule 1
  source_ip:
  - 192.168.100.104
  - 192.168.100.105
  destination_ip:
  - 192.168.0.100
  service:
  - tcp_9000
  - tcp_9100-9200
- description: Another Ansible test rule 2
  source_ip:
  - 192.168.100.204
  - 192.168.100.205
  - 192.168.100.206
  - 192.168.100.207
  destination_ip:
  - 8.8.8.8
  - 192.168.0.42
  service:
  - udp_1053-2053
  - tcp_1053-2053
- description: Another Ansible test rule 3
  source_ip:
  - 192.168.100.204
  destination_ip:
  - 192.168.0.42
  service:
  - udp_123
- description: Another Ansible test rule 4
  source_ip:
  - 192.168.100.204
  - 192.168.100.205
  destination_ip:
  - 192.168.0.100
  service:
  - tcp_1-65535
- description: Another Ansible test rule 5
  source_ip:
  - 192.168.100.204
  - 192.168.100.207
  destination_ip:
  - 8.8.8.8
  service:
  - tcp_8081

Since the PA firewall is zone based I read the following CSV file to the playbook quicker. The CSV table contains the firewall (or device-group) the network and the Security zone that the network belongs to. Without this, I would need to perform a lot more tasks looking this information on each pass.

fwzones.csv: ASCII text

LABPA,192.168.0.0/24,AWS-PROD
LABPA,192.168.100.0/24,AWS-DEV
LABPA,0.0.0.0/0,Layer3-Outside

The following is the inventory in my lab. I don’t recommend storing any credentials here.

inventory: ASCII text

[all:vars]
ansible_connection="local"
ansible_python_interpreter="/usr/bin/env python"
username="admin"
password="admin"
 
[labpa]
labpa01

The following is my main playbook. It will prompt for username password credentials and read the input variables related to the change. Since this is a sample, I am only calling a single task list “panos_security_rule.yaml” which is responsible for managing the security rules on the PA.

main.yaml: a /usr/bin/ansible-playbook -f 10 script text executable, ASCII text

#!/usr/local/bin/ansible-playbook -f 10
---
- name: "manage panos devices"
hosts: labpa01
connection: local
gather_facts: False

vars_prompt:

- name: "username"
prompt: "Username"
private: no

- name: "password"
prompt: "Password"

vars:

- panos_provider:
ip_address: "{{ inventory_hostname }}"
username: "{{ username | default('admin') }}"
password: "{{ password | default('admin') }}"

pre_tasks:

- name: "fail: check for required input"
fail:
msg: "Example: ./main.yaml -e state=present -e er=./ER/CO99999.yaml"
when: (er is undefined) and (state is undefined)

- name: "include_vars: load security rules"
include_vars:
file: "{{ er }}"

roles:
- role: PaloAltoNetworks.paloaltonetworks

tasks:

- name: "include: create panos security rule"
include: panos_security_rule.yaml
with_indexed_items: "{{ security_rule }}"
when: state is defined

handlers:

- name: "commit pending changes"
local_action:
module: panos_commit
provider: "{{ panos_provider }}"

The following is my task list for managing PanOS security rules. If I were to manage any other vendors firewall I would make it read the same input and just simply create a different task list for that vendor device type. There are two tricks that I am performing within this tasklist… I am reading the fwzones.csv file into a variable for lookups. I am also calling another task list that will build the L4 service groups that will be referenced in the security rule.

panos_security_rule.yaml: ASCII text

## Manage security rules on a Palo Alto Firewall
## Requires: panos_object_service.yaml
#
## Vars Example:
#
# ticket: CO99999
# security_rule:
# - source_ip: ["192.168.0.100"]
#   destination_ip: ["any"]
#   service: ["tcp_9000"]
#   description: "Ansible test rule 0"
#
## Task Example:
#
#  - name: "include: create panos security rule"
#    include: panos_security_rule.yaml
#    with_indexed_items: "{{ security_rule }}"
#    when: state is defined
#
---
 
###
# Derive firewall zone and devicegroup from prebuilt CSV.
# Normally we would retrieve this from a functional IPAM.
###
 
# Example CSV file
#
# devicegroup,192.168.0.0/24,prod
# devicegroup,192.168.100.0/24,dev
# devicegroup,0.0.0.0/0,outside

- name: "read_csv: read firewall zones from csv"
  local_action:
    module: read_csv
    path: fwzones.csv
    fieldnames: devicegroup,network,zone
  register: fwzones
  run_once: true

- name: "set_fact: source details"
  set_fact:
    source_dgrp: "{{ item_tmp.1['devicegroup'] }}"
    source_addr: "{{ source_addr|default([]) + [ item_tmp.0 ] }}"
    source_zone: "{{ source_zone|default([]) + [ item_tmp.1['zone'] ] }}"
  with_nested:
  - "{{ item.1.source_ip }}"
  - "{{ fwzones.list }}"
  loop_control:
    loop_var: item_tmp
  when: ( item_tmp.0|ipaddr('int') >= item_tmp.1['network']|ipaddr('network')|ipaddr('int') ) and
        ( item_tmp.0|ipaddr('int') <= item_tmp.1['network']|ipaddr('broadcast')|ipaddr('int') ) and
        ( item_tmp.1['network']|ipaddr('int') != "0/0" )

- name: "set_fact: destination zone"
  set_fact:
    destination_dgrp: "{{ item_tmp.1['devicegroup'] }}"
    destination_zone: "{{ destination_zone|default([]) + [ item_tmp.1['zone'] ] }}"
  with_nested:
  - "{{ item.1.destination_ip }}"
  - "{{ fwzones.list }}"
  loop_control:
    loop_var: item_tmp
  when: ( item_tmp.0|ipaddr('int') >= item_tmp.1['network']|ipaddr('network')|ipaddr('int') ) and
        ( item_tmp.0|ipaddr('int') <= item_tmp.1['network']|ipaddr('broadcast')|ipaddr('int') ) and
        ( item_tmp.1['devicegroup'] == source_dgrp ) and ( destination_zone|default([])|length < item.1.destination_ip|unique|length )
 
##
# Done collecting firewall zone & devicegroup.
##

- name: "set_fact: services"
  set_fact:
    services: "{{ services|default([]) + [ service ] }}"
    service_list: "{{ service_list|default([]) + [ {\"protocol\": {service.split('_')[0]: {\"port\": service.split('_')[1]}}, \"name\": service }] }}"
  with_items: "{{ item.1.service }}"
  loop_control:
    loop_var: service

- name: "include: create panos service object"
  include: panos_object_service.yaml
  with_items: "{{ service_list|unique }}"
  loop_control:
    loop_var: service
  when: (state == "present")
 
###
# Testing against a single PA firewall, uncomment if running against Panorama
###

- name: "panos_security_rule: firewall rule"
  local_action:
    module: panos_security_rule
    provider: "{{ panos_provider }}"
    state: "{{ state }}"
    rule_name: "{{ ticket|upper }}-{{ item.0 }}"
    description: "{{ item.1.description }}"
    tag_name: "ansible"
    source_zone: "{{ source_zone|unique }}"
    source_ip: "{{ source_addr|unique }}"
    destination_zone: "{{ destination_zone|unique }}"
    destination_ip: "{{ item.1.destination_ip|unique }}"
    service: "{{ services|unique }}"
#   devicegroup: "{{ source_dgrp|unique }}"
    action: "allow"
    commit: "False"
  notify:
  - commit pending changes

- name: "include: create panos service object"
  include: panos_object_service.yaml
  with_items: "{{ service_list|unique }}"
  loop_control:
    loop_var: service
  when: (state == "absent")

- name: "set_fact: clear facts from run"
  set_fact:
    services: []
    service_list: []
    source_dgrp: ""
    source_addr: []
    source_zone: []
    destination_dgrp: ""
    destination_addr: []
    destination_zone: []

The following will parse the “service” variable from the input and will manage the creation or removal of its service group. This is probably not best practice, but I like to initially build all PA rules as L4 then after a month to bake in, I will use the Expedition tool or the PanOS9 AppID migration tool to convert rules to L7 later. I never assume that an app owner knows how their application works, which is why I choose to migrate to L7 rules based on what I actually see in the logs.

panos_object_service.yaml: ASCII text

## Var Example:
#
#  services:
#  - { name: service-abc, protocol: { tcp: { port: '5000,6000-7000' } } }
#
## Task Example:
#
#  - name: "include: create panos address object"
#    include: panos_object_service.yaml state="absent"
#    with_items: "{{ services }}"
#    loop_control:
#      loop_var: service
#
---
- name: attempt to locate existing address
  block:

  - name: "panos_object: service - find {{ service.name }}"
    local_action:
      module: panos_object
      ip_address: "{{ inventory_hostname }}"
      username: "{{ username }}"
      password: "{{ password }}"
      serviceobject: "{{ service.name }}"
      devicegroup: "{{ devicegroup | default('') }}"
      operation: "find"
    register: result

  - name: 'set_fact: existing service object'
    set_fact:
      existing: "{{ result.stdout_lines|from_json|json_query('entry')|regex_replace('@') }}"
    when: (state == "present")

  rescue:

  - name: "panos_object: service - add {{ service.name }}"
    local_action:
      module: panos_object
      ip_address: "{{ inventory_hostname }}"
      username: "{{ username }}"
      password: "{{ password }}"
      serviceobject: "{{ service.name }}"
      protocol: "{{ service.protocol | flatten | list | join('\", \"') }}"
      destination_port: "{{ service | json_query('protocol.*.port') | list | join('\", \"') }}"
      description: "{{ service.description | default('') }}"
      devicegroup: "{{ devicegroup | default('') }}"
      operation: 'add'
    when: (state == "present")

- name: "panos_object: service - update {{ service.name }}"
  local_action:
    module: panos_object
    ip_address: "{{ inventory_hostname }}"
    username: "{{ username }}"
    password: "{{ password }}"
    serviceobject: "{{ service.name }}"
    protocol: "{{ service.protocol | flatten | list | join('\", \"') }}"
    destination_port: "{{ service | json_query('protocol.*.port') | list | join('\", \"') }}"
    description: "{{ service.description | default('') }}"
    devicegroup: "{{ devicegroup | default('') }}"
    operation: 'update'
  when: (state == "present") and (existing is defined) and (existing != service)

- name: "panos_object: service - delete {{ service.name }}"
  local_action:
    module: panos_object
    ip_address: "{{ inventory_hostname }}"
    username: "{{ username }}"
    password: "{{ password }}"
    serviceobject: "{{ service.name }}"
    devicegroup: "{{ devicegroup | default('') }}"
    operation: 'delete'
  ignore_errors: yes
  when: (state == "absent") and (result.stdout_lines is defined)