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) |