21 - PanOS
This project is me documenting my journey of learning Ansible that is focused on network engineering. It’s not a “how-to guide” per-say, more of a diary. A lot of information on here is so I can come back to and reference later. I also learn best when teaching someone, and this is kind of me teaching.
Part 21: Palo Alto PAN-OS Network Automation
Every platform covered so far — IOS, NX-OS, Junos — is fundamentally a routing/switching OS that you configure via CLI or NETCONF. PAN-OS is different in kind, not just degree. It’s a security operating system built around an XML API, where configuration is expressed as a tree of named objects that reference each other. You don’t write ACL lines — you create address objects, group them, define zones, write application-based security rules that reference those objects, and commit. Nothing takes effect until commit, and commit is all-or-nothing. Understanding the object hierarchy is what makes PAN-OS automation make sense.
21.1 — The PAN-OS Model: Objects, References, and Commit
API-First Architecture
PAN-OS has no meaningful CLI-driven configuration model. The CLI exists, but the underlying system is XML — every configuration element is an XML node in a hierarchy rooted at /config. The REST API and the XML API both read and write this tree directly. The paloaltonetworks.panos collection talks to the XML API, not SSH CLI.
Traditional network OS: PAN-OS:
SSH → CLI → parser → HTTPS → XML API → config tree
running config │
├── /config/devices/entry/vsys/entry/
│ ├── address/ ← address objects
│ ├── address-group/ ← address groups
│ ├── service/ ← service objects
│ ├── security/rules/ ← security policy
│ └── nat/rules/ ← NAT policy
└── /config/devices/entry/network/
├── interface/ ← interfaces
└── zone/ ← security zonesThe Object Hierarchy
PAN-OS configuration is object-based — every element that can be named and reused is an object, and rules reference objects by name rather than by value. This is the hierarchy:
Level 1 — Zones (trust, untrust, dmz...)
└── Level 2 — Interfaces (assigned to zones)
└── Level 3 — Address Objects (named IP addresses/ranges/FQDNs)
└── Level 4 — Address Groups (named collections of address objects)
└── Level 5 — Service Objects (named port/protocol combos)
└── Level 6 — Security Rules (reference all of the above)
└── Level 7 — NAT Rules (reference zones and addresses)A security rule doesn’t contain source: 192.168.1.0/24. It contains source: INTERNAL_SERVERS — where INTERNAL_SERVERS is an address group that contains address objects SERVER-01 (192.168.1.10) and SERVER-02 (192.168.1.20). When a server IP changes, I update one address object and the change propagates to every rule that references the group. This is the PAN-OS way.
The Commit Model
Like Junos, nothing takes effect until commit. Unlike Junos, there’s no NETCONF candidate — PAN-OS has a running config (active) and a candidate config (uncommitted changes). Every API call that modifies configuration writes to the candidate. commit pushes candidate to running.
panos_address_object task → Writes to candidate config
panos_security_rule task → Writes to candidate config
panos_nat_rule task → Writes to candidate config
│
panos_commit_firewall
│
All changes become active simultaneouslyThe critical implication for Ansible: object creation tasks must complete before rule creation tasks, because rules reference objects by name. If the address group doesn’t exist when the security rule is created, the rule creation fails. Dependency ordering is mandatory.
21.2 — Collection Setup
# Install the Palo Alto collection
ansible-galaxy collection install paloaltonetworks.panos
# Install the Python PAN-OS library
pip install pan-python pan-os-python --break-system-packages
# Verify
python3 -c "import panos; print('pan-os-python OK')"Connection Model
The panos collection uses HTTPS to the management interface — not SSH, not NETCONF:
# group_vars/panos_devices.yml
ansible_network_os: paloaltonetworks.panos.panos
ansible_connection: local # ← panos modules run locally and make HTTPS calls
# Not network_cli, not netconf
# Connection credentials — set in host_vars or vault
# ansible_host: the firewall management IP
# ansible_user: admin (or API-only account)
# ansible_password: vaultedThe ansible_connection: local deserves explanation. PAN-OS modules don’t use Ansible’s standard connection plugins at all. Each module makes its own HTTPS API calls to the firewall using the pan-os-python library. Ansible runs the module on the control node (local), and the module itself handles the HTTPS connection to the firewall. This means PAN-OS tasks in the play recap show as running on localhost, not on the firewall.
API Key Authentication (Preferred)
# Generate an API key from the firewall (one-time)
curl -k -X GET \
"https://172.16.0.51/api/?type=keygen&user=ansible&password=YourPassword"
# Returns: <response status="success"><result><key>LUFRPT14...</key></result></response>
# Store the key in vault
ansible-vault encrypt_string --vault-id [email protected]/lab.txt \
'LUFRPT14...' --name 'vault_panos_api_key'# host_vars/panos-fw01.yml
api_key: "{{ vault_panos_api_key }}"
# Use api_key instead of username/password for all panos module calls21.3 — The PAN-OS Data Model
Built around the object hierarchy: zones and interfaces first, then objects, then rules.
cat > ~/projects/ansible-network/inventory/host_vars/panos-fw01.yml << 'EOF'
---
# =============================================================
# panos-fw01 — Palo Alto PAN-OS Firewall (standalone)
# Lab image: pan-os/panos-vm in Containerlab
# =============================================================
device_hostname: panos-fw01
ansible_host: 172.16.0.51
api_key: "{{ vault_panos_api_key }}"
# ── Zones ─────────────────────────────────────────────────────────
# Zones are the foundation — every interface belongs to a zone
# and every security rule references source/destination zones
zones:
- name: untrust
mode: layer3
description: "Internet-facing zone"
enable_userid: false
- name: trust
mode: layer3
description: "Internal network zone"
enable_userid: true
- name: dmz
mode: layer3
description: "DMZ — public-facing servers"
enable_userid: false
- name: mgmt-zone
mode: layer3
description: "Out-of-band management zone"
enable_userid: false
# ── Interfaces ────────────────────────────────────────────────────
interfaces:
- name: ethernet1/1
mode: layer3
zone: untrust
description: "WAN | To upstream router"
ip: 10.10.10.2
prefix: 30
management_profile: ~ # No management access from untrust
- name: ethernet1/2
mode: layer3
zone: trust
description: "LAN | Internal network"
ip: 192.168.1.1
prefix: 24
management_profile: ALLOW_MGMT
- name: ethernet1/3
mode: layer3
zone: dmz
description: "DMZ | Public-facing servers"
ip: 172.16.10.1
prefix: 24
management_profile: ~
# ── Address Objects ───────────────────────────────────────────────
# Granular named objects — represent individual IPs, subnets, or FQDNs
address_objects:
- name: INTERNAL-NET
type: ip-netmask
value: 192.168.1.0/24
description: "Internal LAN network"
tags: [internal, production]
- name: SERVER-WEB-01
type: ip-netmask
value: 172.16.10.10/32
description: "Primary web server"
tags: [dmz, web, production]
- name: SERVER-WEB-02
type: ip-netmask
value: 172.16.10.11/32
description: "Secondary web server"
tags: [dmz, web, production]
- name: SERVER-APP-01
type: ip-netmask
value: 172.16.10.20/32
description: "Application server"
tags: [dmz, app, production]
- name: SERVER-DB-01
type: ip-netmask
value: 172.16.10.30/32
description: "Database server"
tags: [dmz, db, production]
- name: DNS-GOOGLE-PRIMARY
type: ip-netmask
value: 8.8.8.8/32
description: "Google Public DNS primary"
tags: [dns, internet]
- name: DNS-GOOGLE-SECONDARY
type: ip-netmask
value: 8.8.4.4/32
description: "Google Public DNS secondary"
tags: [dns, internet]
- name: MGMT-WORKSTATION
type: ip-netmask
value: 192.168.1.100/32
description: "Network admin workstation"
tags: [internal, management]
- name: SYSLOG-SERVER
type: ip-netmask
value: 172.16.0.100/32
description: "Centralized syslog server"
tags: [internal, management]
# ── Address Groups ────────────────────────────────────────────────
# Groups aggregate address objects — rules reference groups, not individual objects
# When a new web server is added, add it to WEB-SERVERS group — all rules update automatically
address_groups:
- name: WEB-SERVERS
description: "All public-facing web servers"
members:
- SERVER-WEB-01
- SERVER-WEB-02
tags: [dmz, web]
- name: APP-SERVERS
description: "Application tier servers"
members:
- SERVER-APP-01
tags: [dmz, app]
- name: DB-SERVERS
description: "Database tier servers"
members:
- SERVER-DB-01
tags: [dmz, db]
- name: ALL-DMZ-SERVERS
description: "All servers in DMZ"
members:
- SERVER-WEB-01
- SERVER-WEB-02
- SERVER-APP-01
- SERVER-DB-01
tags: [dmz]
- name: DNS-SERVERS
description: "Permitted DNS resolvers"
members:
- DNS-GOOGLE-PRIMARY
- DNS-GOOGLE-SECONDARY
tags: [dns]
- name: MGMT-HOSTS
description: "Management workstations and servers"
members:
- MGMT-WORKSTATION
- SYSLOG-SERVER
tags: [management]
# ── Service Objects ───────────────────────────────────────────────
# Named port/protocol objects — reused across rules
service_objects:
- name: SVC-HTTP
protocol: tcp
destination_port: 80
description: "HTTP"
- name: SVC-HTTPS
protocol: tcp
destination_port: 443
description: "HTTPS"
- name: SVC-SSH
protocol: tcp
destination_port: 22
description: "SSH management"
- name: SVC-NETCONF
protocol: tcp
destination_port: 830
description: "NETCONF"
- name: SVC-DNS
protocol: udp
destination_port: 53
description: "DNS"
- name: SVC-MYSQL
protocol: tcp
destination_port: 3306
description: "MySQL database"
- name: SVC-SYSLOG
protocol: udp
destination_port: 514
description: "Syslog"
# ── Service Groups ────────────────────────────────────────────────
service_groups:
- name: SVC-WEB
description: "HTTP and HTTPS"
members:
- SVC-HTTP
- SVC-HTTPS
# ── Security Rules ────────────────────────────────────────────────
# Rules are evaluated top-to-bottom — order matters
# Each rule references zones, address objects/groups, and applications
security_rules:
- name: ALLOW-INTERNET-TO-WEB
description: "Permit inbound HTTP/HTTPS to DMZ web servers"
source_zone: [untrust]
destination_zone: [dmz]
source_address: [any]
destination_address: [WEB-SERVERS]
application: [web-browsing, ssl]
service: [application-default]
action: allow
log_start: false
log_end: true
log_forwarding: SYSLOG-FORWARDING
tags: [internet-inbound, production]
- name: ALLOW-WEB-TO-APP
description: "Permit web tier to app tier communication"
source_zone: [dmz]
destination_zone: [dmz]
source_address: [WEB-SERVERS]
destination_address: [APP-SERVERS]
application: [any]
service: [SVC-HTTPS]
action: allow
log_end: true
log_forwarding: SYSLOG-FORWARDING
tags: [dmz-internal, production]
- name: ALLOW-APP-TO-DB
description: "Permit app tier to database tier"
source_zone: [dmz]
destination_zone: [dmz]
source_address: [APP-SERVERS]
destination_address: [DB-SERVERS]
application: [mysql]
service: [application-default]
action: allow
log_end: true
log_forwarding: SYSLOG-FORWARDING
tags: [dmz-internal, production]
- name: ALLOW-INTERNAL-OUTBOUND
description: "Permit internal users to internet (web)"
source_zone: [trust]
destination_zone: [untrust]
source_address: [INTERNAL-NET]
destination_address: [any]
application: [web-browsing, ssl, dns]
service: [application-default]
action: allow
log_end: true
log_forwarding: SYSLOG-FORWARDING
tags: [internal-outbound, production]
- name: ALLOW-INTERNAL-DNS
description: "Permit internal DNS resolution"
source_zone: [trust]
destination_zone: [untrust]
source_address: [INTERNAL-NET]
destination_address: [DNS-SERVERS]
application: [dns]
service: [application-default]
action: allow
log_end: false
tags: [internal-outbound, dns]
- name: ALLOW-MGMT-SSH
description: "Permit admin SSH to all zones from management hosts"
source_zone: [trust]
destination_zone: [dmz, untrust, mgmt-zone]
source_address: [MGMT-HOSTS]
destination_address: [any]
application: [ssh]
service: [application-default]
action: allow
log_end: true
log_forwarding: SYSLOG-FORWARDING
tags: [management, production]
- name: ALLOW-SYSLOG-OUTBOUND
description: "Permit syslog forwarding from all zones"
source_zone: [trust, dmz]
destination_zone: [trust]
source_address: [ALL-DMZ-SERVERS]
destination_address: [SYSLOG-SERVER]
application: [syslog]
service: [application-default]
action: allow
log_end: false
tags: [management, logging]
- name: DENY-ALL
description: "Default deny — log all blocked traffic"
source_zone: [any]
destination_zone: [any]
source_address: [any]
destination_address: [any]
application: [any]
service: [any]
action: deny
log_start: false
log_end: true
log_forwarding: SYSLOG-FORWARDING
tags: [default-deny]
# ── NAT Rules ─────────────────────────────────────────────────────
nat_rules:
- name: INTERNET-OUTBOUND-PAT
description: "Source NAT — internal users to internet (PAT)"
source_zone: [trust]
destination_zone: [untrust]
source_address: [INTERNAL-NET]
destination_address: [any]
nat_type: ipv4
source_translation:
type: dynamic-ip-and-port
interface_address:
interface: ethernet1/1
tags: [nat, outbound]
- name: DNAT-HTTP-TO-WEB
description: "Destination NAT — inbound HTTP to web server"
source_zone: [untrust]
destination_zone: [untrust] # Pre-NAT zone — always untrust for inbound DNAT
source_address: [any]
destination_address: [10.10.10.2] # Firewall WAN IP (pre-NAT)
service: SVC-HTTP
destination_translation:
translated_address: SERVER-WEB-01
translated_port: 80
tags: [nat, inbound, web]
- name: DNAT-HTTPS-TO-WEB
description: "Destination NAT — inbound HTTPS to web server"
source_zone: [untrust]
destination_zone: [untrust]
source_address: [any]
destination_address: [10.10.10.2]
service: SVC-HTTPS
destination_translation:
translated_address: SERVER-WEB-01
translated_port: 443
tags: [nat, inbound, web]
# ── Log Forwarding Profile ────────────────────────────────────────
log_forwarding_profiles:
- name: SYSLOG-FORWARDING
description: "Forward traffic logs to syslog server"
match_list:
- name: ALL-TRAFFIC
log_type: traffic
filter: All Logs
syslog_destinations:
- name: SYSLOG-SERVER-PROFILE
syslog_server: 172.16.0.100
facility: LOG_USER
format: BSD
EOF21.4 — The PAN-OS Master Playbook
nano ~/projects/ansible-network/playbooks/deploy/deploy_panos.yml---
# =============================================================
# deploy_panos.yml — PAN-OS complete configuration
# Object hierarchy: zones → interfaces → objects → groups → rules
# Two commit approaches:
# Simple: handler fires after all changes
# Production: explicit two-phase commit with verification
#
# Common invocations:
# Full run: ansible-playbook deploy_panos.yml
# Objects only: ansible-playbook deploy_panos.yml --tags objects
# Rules only: ansible-playbook deploy_panos.yml --tags rules
# Commit only: ansible-playbook deploy_panos.yml --tags commit
# Diff (no push): ansible-playbook deploy_panos.yml --check
# =============================================================
- name: "PAN-OS | Deploy firewall configuration"
hosts: panos_devices
gather_facts: false
connection: local # panos modules make their own HTTPS API calls
vars:
panos_provider:
ip_address: "{{ ansible_host }}"
api_key: "{{ api_key }}"
# Alternative: username/password
# username: "{{ ansible_user }}"
# password: "{{ ansible_password }}"
tasks:
# ── Pre-flight ────────────────────────────────────────────────
- name: "Pre-flight | Gather PAN-OS facts"
paloaltonetworks.panos.panos_facts:
provider: "{{ panos_provider }}"
register: panos_device_facts
tags: always
- name: "Pre-flight | Display device summary"
ansible.builtin.debug:
msg:
- "Device: {{ inventory_hostname }}"
- "Hostname: {{ panos_device_facts.ansible_facts.ansible_net_hostname }}"
- "Version: {{ panos_device_facts.ansible_facts.ansible_net_version }}"
- "Model: {{ panos_device_facts.ansible_facts.ansible_net_model }}"
verbosity: 1
tags: always
# ── Section 1: Zones ──────────────────────────────────────────
# Zones must exist before interfaces can be assigned to them
# PAN-OS difference: zones are first-class config objects, not interface attributes
- block:
- name: "Zones | Create security zones"
paloaltonetworks.panos.panos_zone:
provider: "{{ panos_provider }}"
zone: "{{ item.name }}"
mode: "{{ item.mode }}"
enable_userid: "{{ item.enable_userid | default(false) }}"
state: present
loop: "{{ zones | default([]) }}"
loop_control:
label: "Zone: {{ item.name }} ({{ item.mode }})"
notify: Commit PAN-OS configuration
tags: [zones, base]
# ── Section 2: Interfaces ─────────────────────────────────────
# Interfaces assigned to zones after zones exist
- block:
- name: "Interfaces | Configure L3 interfaces"
paloaltonetworks.panos.panos_interface:
provider: "{{ panos_provider }}"
if_name: "{{ item.name }}"
mode: "{{ item.mode }}"
ip: ["{{ item.ip }}/{{ item.prefix }}"]
zone_name: "{{ item.zone }}"
comment: "{{ item.description | default('') }}"
state: present
loop: "{{ interfaces | default([]) }}"
loop_control:
label: "{{ item.name }} → zone {{ item.zone }}: {{ item.ip }}/{{ item.prefix }}"
notify: Commit PAN-OS configuration
tags: [interfaces, base]
# ── Section 3: Address Objects ────────────────────────────────
# Must exist before address groups or rules reference them
- block:
- name: "Objects | Create address objects"
paloaltonetworks.panos.panos_address_object:
provider: "{{ panos_provider }}"
name: "{{ item.name }}"
value: "{{ item.value }}"
address_type: "{{ item.type }}"
description: "{{ item.description | default('') }}"
tag: "{{ item.tags | default([]) }}"
state: present
loop: "{{ address_objects | default([]) }}"
loop_control:
label: "Address object: {{ item.name }} ({{ item.value }})"
notify: Commit PAN-OS configuration
tags: [objects, address-objects]
# ── Section 4: Address Groups ─────────────────────────────────
# Must exist before security rules reference them
- block:
- name: "Objects | Create address groups"
paloaltonetworks.panos.panos_address_group:
provider: "{{ panos_provider }}"
name: "{{ item.name }}"
static_value: "{{ item.members }}"
description: "{{ item.description | default('') }}"
tag: "{{ item.tags | default([]) }}"
state: present
loop: "{{ address_groups | default([]) }}"
loop_control:
label: "Address group: {{ item.name }} ({{ item.members | length }} members)"
notify: Commit PAN-OS configuration
tags: [objects, address-groups]
# ── Section 5: Service Objects ────────────────────────────────
- block:
- name: "Objects | Create service objects"
paloaltonetworks.panos.panos_service_object:
provider: "{{ panos_provider }}"
name: "{{ item.name }}"
protocol: "{{ item.protocol }}"
destination_port: "{{ item.destination_port | string }}"
description: "{{ item.description | default('') }}"
state: present
loop: "{{ service_objects | default([]) }}"
loop_control:
label: "Service: {{ item.name }} ({{ item.protocol }}/{{ item.destination_port }})"
notify: Commit PAN-OS configuration
- name: "Objects | Create service groups"
paloaltonetworks.panos.panos_service_group:
provider: "{{ panos_provider }}"
name: "{{ item.name }}"
value: "{{ item.members }}"
state: present
loop: "{{ service_groups | default([]) }}"
loop_control:
label: "Service group: {{ item.name }}"
notify: Commit PAN-OS configuration
tags: [objects, service-objects]
# ── Section 6: Log Forwarding Profile ─────────────────────────
# Must exist before security rules reference it
- block:
- name: "Logging | Create log forwarding profiles"
paloaltonetworks.panos.panos_log_forwarding_profile:
provider: "{{ panos_provider }}"
name: "{{ item.name }}"
description: "{{ item.description | default('') }}"
state: present
loop: "{{ log_forwarding_profiles | default([]) }}"
loop_control:
label: "Log profile: {{ item.name }}"
notify: Commit PAN-OS configuration
- name: "Logging | Configure log forwarding match lists"
paloaltonetworks.panos.panos_log_forwarding_profile_match_list:
provider: "{{ panos_provider }}"
log_forwarding_profile: "{{ item.0.name }}"
name: "{{ item.1.name }}"
log_type: "{{ item.1.log_type }}"
filter: "{{ item.1.filter }}"
syslog: "{{ item.1.syslog_destinations | map(attribute='name') | list }}"
state: present
loop: "{{ log_forwarding_profiles | default([]) | subelements('match_list') }}"
loop_control:
label: "{{ item.0.name }}/{{ item.1.name }}"
notify: Commit PAN-OS configuration
tags: [logging, objects]
# ── Section 7: Security Rules ─────────────────────────────────
# All referenced objects must exist before creating rules
# Rule ORDER matters — rules are evaluated top-to-bottom
# The loop preserves data model order
- block:
- name: "Security | Create security policy rules"
paloaltonetworks.panos.panos_security_rule:
provider: "{{ panos_provider }}"
rule_name: "{{ item.name }}"
description: "{{ item.description | default('') }}"
source_zone: "{{ item.source_zone }}"
destination_zone: "{{ item.destination_zone }}"
source_ip: "{{ item.source_address }}"
destination_ip: "{{ item.destination_address }}"
application: "{{ item.application }}"
service: "{{ item.service }}"
action: "{{ item.action }}"
log_start: "{{ item.log_start | default(false) }}"
log_end: "{{ item.log_end | default(true) }}"
log_setting: "{{ item.log_forwarding | default(omit) }}"
tag_name: "{{ item.tags | default([]) }}"
state: present
loop: "{{ security_rules | default([]) }}"
loop_control:
label: "Rule: {{ item.name }} ({{ item.action }})"
notify: Commit PAN-OS configuration
- name: "Security | Enforce rule ordering"
paloaltonetworks.panos.panos_security_rule:
provider: "{{ panos_provider }}"
rule_name: "{{ item.name }}"
location: bottom
state: present
loop: "{{ security_rules | default([]) | reverse | list }}"
loop_control:
label: "Ordering: {{ item.name }}"
# Move each rule to bottom in reverse order = original order at top
# This ensures the data model order is enforced even if rules already exist
notify: Commit PAN-OS configuration
tags: [security-rules, rules]
# ── Section 8: NAT Rules ──────────────────────────────────────
- block:
- name: "NAT | Create NAT rules"
paloaltonetworks.panos.panos_nat_rule:
provider: "{{ panos_provider }}"
rule_name: "{{ item.name }}"
description: "{{ item.description | default('') }}"
source_zone: "{{ item.source_zone }}"
destination_zone: "{{ item.destination_zone }}"
source_ip: "{{ item.source_address }}"
destination_ip: "{{ item.destination_address }}"
service: "{{ item.service | default('any') }}"
nat_type: "{{ item.nat_type | default('ipv4') }}"
# Source NAT
snat_type: >-
{{ item.source_translation.type
if item.source_translation is defined else omit }}
snat_interface: >-
{{ item.source_translation.interface_address.interface
if item.source_translation is defined
and item.source_translation.type == 'dynamic-ip-and-port'
else omit }}
# Destination NAT
dnat_address: >-
{{ item.destination_translation.translated_address
if item.destination_translation is defined else omit }}
dnat_port: >-
{{ item.destination_translation.translated_port | string
if item.destination_translation is defined else omit }}
tag_name: "{{ item.tags | default([]) }}"
state: present
loop: "{{ nat_rules | default([]) }}"
loop_control:
label: "NAT rule: {{ item.name }}"
notify: Commit PAN-OS configuration
tags: [nat-rules, rules]
# ── Handler: Commit (simple approach) ───────────────────────────
handlers:
- name: Commit PAN-OS configuration
paloaltonetworks.panos.panos_commit_firewall:
provider: "{{ panos_provider }}"
description: "Committed by Ansible — {{ lookup('pipe', 'date') }}"
listen: Commit PAN-OS configuration
# Handler fires ONCE at end of play regardless of how many tasks notified it
# This is the simple approach — appropriate when no post-commit verification needed
# For production with verification, use the two-phase play belowPhase 2: Production Commit with Verification
For production environments where post-commit verification is required:
---
# =============================================================
# deploy_panos_commit.yml — Two-phase production commit
# Run after deploy_panos.yml when handler-based commit isn't enough
# Verifies policy is behaving correctly after commit
# =============================================================
- name: "PAN-OS | Production commit with verification"
hosts: panos_devices
gather_facts: false
connection: local
tags: commit
vars:
panos_provider:
ip_address: "{{ ansible_host }}"
api_key: "{{ api_key }}"
tasks:
- name: "Commit | Check for pending changes before commit"
paloaltonetworks.panos.panos_op:
provider: "{{ panos_provider }}"
cmd: "show config list changes"
register: pending_changes
changed_when: false
- name: "Commit | Display pending changes"
ansible.builtin.debug:
msg: "{{ pending_changes.stdout }}"
verbosity: 1
- name: "Commit | Execute commit to firewall"
paloaltonetworks.panos.panos_commit_firewall:
provider: "{{ panos_provider }}"
description: "Production deploy — {{ lookup('pipe', 'date') }}"
admins: ["ansible"] # Commit only changes made by ansible admin
register: commit_result
- name: "Commit | Wait for commit to complete"
paloaltonetworks.panos.panos_op:
provider: "{{ panos_provider }}"
cmd: "show jobs id {{ commit_result.jobid }}"
register: commit_job
until: "'FIN' in commit_job.stdout"
retries: 30
interval: 10
changed_when: false
# PAN-OS commits are asynchronous — they return a job ID
# Poll until the job shows FIN (finished) status
- name: "Verify | Check security policy is active"
paloaltonetworks.panos.panos_op:
provider: "{{ panos_provider }}"
cmd: "show security policy"
register: active_policy
changed_when: false
- name: "Verify | Assert critical rules are present"
ansible.builtin.assert:
that:
- "item.name in active_policy.stdout"
fail_msg: "Critical rule '{{ item.name }}' not found in active policy"
success_msg: "PASS: Rule '{{ item.name }}' is active"
loop: "{{ security_rules | default([]) | selectattr('tags', 'contains', 'production') | list }}"
loop_control:
label: "{{ item.name }}"
- name: "Verify | Check interface status post-commit"
paloaltonetworks.panos.panos_op:
provider: "{{ panos_provider }}"
cmd: "show interface all"
register: interface_status
changed_when: false
- name: "Verify | Display interface summary"
ansible.builtin.debug:
msg: "{{ interface_status.stdout }}"
verbosity: 1
- name: "Commit | Report commit outcome"
ansible.builtin.debug:
msg:
- "═══════════════════════════════════════════════"
- "PAN-OS COMMIT COMPLETE: {{ inventory_hostname }}"
- "Job ID: {{ commit_result.jobid }}"
- "Status: {{ commit_job.stdout | regex_search('Status.*') | default('Unknown') }}"
- "Production rules verified: PASS"
- "═══════════════════════════════════════════════"21.5 — Application-Based Security Rules
PAN-OS App-ID identifies applications by behavior, not port. The application: field in security rules takes App-ID names — not port numbers. This is what makes PAN-OS fundamentally different from ACL-based firewall automation.
# App-ID reference examples for common applications
# These are Palo Alto's built-in application signatures
security_rules:
- name: ALLOW-WEB-BROWSING
source_zone: [trust]
destination_zone: [untrust]
source_address: [any]
destination_address: [any]
application:
- web-browsing # HTTP (port 80) — but only actual web browsing, not raw TCP/80
- ssl # HTTPS (port 443) — TLS-identified web traffic
service: [application-default] # Use the app's default port
action: allow
- name: ALLOW-OFFICE365
source_zone: [trust]
destination_zone: [untrust]
source_address: [INTERNAL-NET]
destination_address: [any]
application:
- ms-office365 # Microsoft Office 365
- ms-teams # Microsoft Teams
- ms-teams-audio # Teams audio specifically
- ms-teams-video # Teams video specifically
- ms-onedrive # OneDrive sync
service: [application-default]
action: allow
- name: BLOCK-SOCIAL-MEDIA
source_zone: [trust]
destination_zone: [untrust]
source_address: [INTERNAL-NET]
destination_address: [any]
application:
- facebook # Facebook (all traffic)
- instagram # Instagram
- tiktok # TikTok
- twitter # Twitter/X
service: [any]
action: deny
log_end: true
- name: ALLOW-ENCRYPTED-UNKNOWN
# When SSL inspection isn't deployed, allow-unknown-ssl is sometimes needed
source_zone: [trust]
destination_zone: [untrust]
source_address: [INTERNAL-NET]
destination_address: [any]
application:
- ssl # Known SSL applications
- unknown-tcp # Unknown TCP — use carefully, audit regularly
service: [application-default]
action: allowApp-ID vs Port-Based Rules: The Key Difference
# Traditional port-based rule (works but misses App-ID benefits):
- name: ALLOW-HTTPS-PORT-443
application: [any] # Any application on port 443
service: [SVC-HTTPS] # TCP/443 specifically
action: allow
# Allows: legitimate HTTPS, but also any app that tunnel over 443
# App-ID based rule (recommended):
- name: ALLOW-SSL-APPLICATIONS
application: [ssl, web-browsing, ms-office365] # Specific app signatures
service: [application-default] # App uses its default port
action: allow
# Allows: only apps that PAN-OS identifies as SSL/web-browsing/Office365
# Blocks: malware using port 443 for C2 that doesn't match SSL signatures21.6 — PAN-OS Module Reference
panos_facts — Device Information
paloaltonetworks.panos.panos_facts:
provider: "{{ panos_provider }}"
# Returns: ansible_net_hostname, ansible_net_version, ansible_net_model,
# ansible_net_serial, ansible_net_uptimepanos_op — Run Operational Commands
paloaltonetworks.panos.panos_op:
provider: "{{ panos_provider }}"
cmd: "show interface all"
# OR XML format:
cmd_is_xml: false
register: op_result
# result in op_result.stdout (text) or op_result.xml (XML)panos_security_rule — Security Policy
paloaltonetworks.panos.panos_security_rule:
provider: "{{ panos_provider }}"
rule_name: ALLOW-WEB
description: "Permit web traffic"
source_zone: [trust]
destination_zone: [untrust]
source_ip: [any] # Or list of address object/group names
destination_ip: [any]
application: [web-browsing, ssl]
service: [application-default]
action: allow # allow | deny | drop | reset-client | reset-server
log_end: true
log_setting: SYSLOG-FORWARDING
location: bottom # top | bottom | before | after
state: present # present | absentpanos_nat_rule — NAT Policy
paloaltonetworks.panos.panos_nat_rule:
provider: "{{ panos_provider }}"
rule_name: OUTBOUND-PAT
source_zone: [trust]
destination_zone: [untrust]
source_ip: [INTERNAL-NET]
destination_ip: [any]
# Source NAT — dynamic IP and port (PAT)
snat_type: dynamic-ip-and-port
snat_interface: ethernet1/1
state: presentpanos_address_object — Address Objects
paloaltonetworks.panos.panos_address_object:
provider: "{{ panos_provider }}"
name: SERVER-WEB-01
value: 172.16.10.10/32
address_type: ip-netmask # ip-netmask | ip-range | fqdn
description: "Primary web server"
tag: [dmz, web, production]
state: presentpanos_commit_firewall — Commit
# Standard commit
paloaltonetworks.panos.panos_commit_firewall:
provider: "{{ panos_provider }}"
description: "Deployed by Ansible"
# Commit only specific admin's changes
paloaltonetworks.panos.panos_commit_firewall:
provider: "{{ panos_provider }}"
admins: ["ansible"]21.7 — Panorama: What Changes
Panorama is Palo Alto’s centralized management platform — it manages multiple firewalls from a single pane. The automation model is the same but with two differences: the target is Panorama (not the firewall directly), and configuration lives in device groups and templates rather than directly on the firewall.
Connection Change
# group_vars/panorama.yml
ansible_host: 172.16.0.60 # Panorama management IP (not firewall)
ansible_connection: local
# host_vars/panorama-01.yml
panos_provider:
ip_address: "{{ ansible_host }}"
api_key: "{{ vault_panorama_api_key }}"Device Groups and Templates
# On Panorama, address objects live in device groups, not vsys
paloaltonetworks.panos.panos_address_object:
provider: "{{ panos_provider }}"
name: SERVER-WEB-01
value: 172.16.10.10/32
address_type: ip-netmask
device_group: PRODUCTION-DG # ← Device group, not vsys
state: present
# Security rules also target device groups
paloaltonetworks.panos.panos_security_rule:
provider: "{{ panos_provider }}"
rule_name: ALLOW-WEB
source_zone: [trust]
destination_zone: [untrust]
application: [web-browsing]
action: allow
device_group: PRODUCTION-DG # ← All rules go to a device group
rulebase: pre-rulebase # pre-rulebase | post-rulebase
state: presentPanorama Commit — Two Steps
Panorama commits have two stages: commit to Panorama (saves to Panorama), then push to devices (deploys to managed firewalls):
# Step 1: Commit to Panorama
paloaltonetworks.panos.panos_commit_panorama:
provider: "{{ panos_provider }}"
description: "Committed to Panorama by Ansible"
# Step 2: Push from Panorama to managed firewalls
paloaltonetworks.panos.panos_push_to_devices:
provider: "{{ panos_provider }}"
device_groups: [PRODUCTION-DG]
description: "Pushed from Panorama by Ansible"
# This deploys the Panorama config to all firewalls in the device groupWhen to Use Panorama vs Standalone
Standalone firewall automation:
- Target: firewall management IP directly
- Objects: in vsys1 (default vsys)
- Commit: panos_commit_firewall
- Use when: single firewall or small number of independently managed firewalls
Panorama automation:
- Target: Panorama IP
- Objects: in device groups and templates
- Commit: panos_commit_panorama → panos_push_to_devices
- Use when: managing many firewalls centrally, shared policy across sites21.8 — Common PAN-OS Automation Gotchas
### 🪲 Gotcha — Rule ordering requires explicit management
panos_security_rule with state: present creates the rule but doesn’t guarantee its position in the rulebase. A new rule added to the middle of the data model list may be created at the bottom of the rulebase. Always enforce order explicitly:
# After creating all rules, enforce order by moving each to bottom in reverse
- name: "Security | Enforce rule ordering"
paloaltonetworks.panos.panos_security_rule:
provider: "{{ panos_provider }}"
rule_name: "{{ item.name }}"
location: bottom
state: present
loop: "{{ security_rules | default([]) | reverse | list }}"
loop_control:
label: "Order: {{ item.name }}"Moving in reverse order from bottom achieves the correct final order: move last rule to bottom, move second-to-last to bottom (it goes above the last), and so on.
### 🪲 Gotcha — Nothing is active until commit — test in check mode first
All panos_* module tasks succeed even if the configuration they produce would cause a commit failure. The commit is where PAN-OS validates consistency. A rule referencing a non-existent address group succeeds as a task but fails at commit time:
# Always run --check first to see what would change without committing
ansible-playbook deploy_panos.yml --check
# Then run without --check — objects and rules pushed to candidate
ansible-playbook deploy_panos.yml --skip-tags commit
# Review the candidate diff via panos_op
ansible panos-fw01 -m paloaltonetworks.panos.panos_op \
-a "provider={{ panos_provider }} cmd='show config diff'"
# Only commit when satisfied
ansible-playbook deploy_panos_commit.yml### 🪲 Gotcha — Commit jobs are asynchronous — always poll for completion
panos_commit_firewall returns a job ID immediately but the commit may take 30-120 seconds. Tasks that run after commit but before it completes will interact with the pre-commit state:
# Always wait for commit completion using panos_op to poll the job
- name: "Wait for commit job to finish"
paloaltonetworks.panos.panos_op:
provider: "{{ panos_provider }}"
cmd: "show jobs id {{ commit_result.jobid }}"
register: job_status
until: "'FIN' in job_status.stdout or 'FAIL' in job_status.stdout"
retries: 30
interval: 10
changed_when: false
- name: "Assert commit succeeded"
ansible.builtin.assert:
that:
- "'FIN' in job_status.stdout"
- "'FAIL' not in job_status.stdout"
fail_msg: "Commit job {{ commit_result.jobid }} failed"### 🪲 Gotcha — application-default service vs specific service objects
In security rules, service: [application-default] tells PAN-OS to match traffic on whatever port the App-ID expects. service: [SVC-HTTPS] tells PAN-OS to match traffic on TCP/443 regardless of App-ID. Mixing them causes unexpected behavior:
# ✅ Consistent — app-default means the app's known port
application: [web-browsing]
service: [application-default] # Web browsing on port 80
# ✅ Consistent — specific service overrides app-default
application: [any]
service: [SVC-HTTPS] # Anything on TCP/443
# ❌ Inconsistent — may not match what you expect
application: [web-browsing] # Web browsing expected on port 80
service: [SVC-HTTPS] # But you're restricting to port 443
# Result: only matches web browsing on port 443 (HTTPS web browsing)
# May miss HTTP web browsing entirelyPAN-OS automation is complete — the XML API model, the object hierarchy, application-based security rules, NAT, log forwarding, and the two commit patterns for simple and production use. Part 22 shifts from platform-specific automation to network-wide operations: compliance checking, bulk fact collection, and generating reports across the entire multi-vendor lab.