Ansible for Cisco ASA: Object Groups, ACLs and NAT Without Locking Yourself Out

Object groups, an inbound ACL and NAT on an ASAv 9.24, built with cisco.asa 7.0.0 and tested with real traffic. Includes the first login password change that stalls Ansible, a no shutdown that is never idempotent, and what overridden would have deleted.

Lab diagram: An Ansible control node (ansible-core 2.21.5, cisco.asa 7.0.0) runs five playbooks (interfaces, asa_ogs, asa_acls, NAT, save_when: modified) over SSH on the management network 10

This is part 7 of the Ansible for network engineers series. Parts 1 to 6 were all IOS XE routers. This one adds a Cisco ASA: interface addressing, object groups, an inbound ACL, NAT and a save that only happens when something changed. A firewall is where a careless playbook hurts most, so every change below went through check mode first and every playbook was run twice to prove the second run changes nothing.

Built in my home lab with ansible-core 2.21.5, cisco.asa 7.0.0, ansible.netcommon 8.7.1 and Python 3.14, against an ASAv running 9.24(1). Behind it are two small Linux hosts: one inside on 10.10.10.10 and one outside on 203.0.113.100, so I could test the rules with real traffic.

Lab diagram: An Ansible control node (ansible-core 2.21.5, cisco.asa 7.0.0) runs five playbooks (interfaces, asa_ogs, asa_acls, NAT, save_when: modified) over SSH on the management network 10.66.10.0/24 to hq-fw1, an ASAv 9.24(1). Inside Gi0/0 10.10.10.1/24 security 100 leads to web1 10.10.10.10; outside Gi0/1 203.0.113.1/24 security 0 leads to client 203.0.113.100. Allowed in teal: client to 203.0.113.10:80 matches OUTSIDE-IN line 1 (WEB-SERVERS + WEB-PORTS) and is translated to 10.10.10.10 by static NAT WEB1. Blocked in orange: client to :2222 hits line 2 deny ip any any log default. Callout: overridden would delete MGMT_IN.
Lab topology: hq-fw1 between web1 and the outside client

Inventory and connection

The firewall gets its own inventory so none of the IOS playbooks from earlier parts can target it by accident.

# fw-inv/hosts.yml
all:
  children:
    firewalls:
      hosts:
        hq-fw1: {ansible_host: 10.66.10.41}
# fw-inv/group_vars/firewalls/connection.yml
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.asa.asa
ansible_user: admin
ansible_password: "{{ vault_asa_password }}"
ansible_become: true
ansible_become_method: enable
ansible_become_password: "{{ vault_asa_enable_password }}"

The two vault_ values live in an encrypted fw-inv/group_vars/firewalls/vault.yml, the same pattern as the routers in part 2. In the post they're placeholders:

vault_asa_password: "<asa-admin-password>"
vault_asa_enable_password: "<asa-enable-password>"

The first connection hangs: a forced password change

My first asa_command run failed after 30 seconds with command timeout triggered. The SSH login worked, and the persistent log showed the session stuck waiting for a prompt. Logging in by hand showed why:

This is your first login. Please set a new password before proceeding.
Enter old password:
Enter new password:
Confirm new password:
ERROR: Password must contain:
ERROR:  a value that has less than 3 repetitive or sequential ASCII characters.

ASA 9.24 made me change the bootstrap password on first login, and it rejected my first new password for having three sequential characters. Ansible has no prompt pattern for this, so it waits until command_timeout. Log in once by hand, set a compliant password, put it in the vault, and then run Ansible. The same thing will bite you on any freshly built ASA in an automated pipeline.

There's a second first contact error worth knowing. With host_key_checking on and libssh in use (see case 8 in part 6), libssh refused the very first run because it had never seen the ASA's host key. I added the key to known_hosts before going further.

The baseline after that:

"Cisco Adaptive Security Appliance Software Version 9.24(1) ",
"GigabitEthernet0/0         unassigned      YES unset  administratively down up  ",
"GigabitEthernet0/1         unassigned      YES unset  administratively down up  ",
"access-list MGMT_IN extended permit tcp any any eq ssh"
"access-group MGMT_IN in interface management"

Note MGMT_IN. It was there before Ansible touched the box, and it comes back later.

Interfaces: no shutdown is never idempotent

The first version of the interface playbook used asa_config with nameif, security-level, ip address and no shutdown. It worked, but the second run still reported changed=2:

changed: [hq-fw1] => {"changed": true, "updates": ["interface GigabitEthernet0/0", "no shutdown"]}
changed: [hq-fw1] => {"changed": true, "updates": ["interface GigabitEthernet0/1", "no shutdown"]}

asa_config compares your lines against the running config, and the ASA never prints no shutdown. An enabled interface just has no shutdown line:

interface GigabitEthernet0/0
 nameif inside
 security-level 100
 ip address 10.10.10.1 255.255.255.0

So no shutdown is never found and always gets sent. A playbook that always reports changed is a problem on a firewall, because you stop reading the recap. I split it: addressing with asa_config, then a read of the interface, then no shutdown only where the running config actually contains shutdown.

---
- name: Data interfaces (inside and outside)
  hosts: firewalls
  gather_facts: false
  vars:
    asa_ifaces:
      - {name: GigabitEthernet0/0, nameif: inside, level: 100, ip: 10.10.10.1 255.255.255.0}
      - {name: GigabitEthernet0/1, nameif: outside, level: 0, ip: 203.0.113.1 255.255.255.0}
  tasks:
    - name: Addressing
      cisco.asa.asa_config:
        parents: "interface {{ item.name }}"
        lines:
          - "nameif {{ item.nameif }}"
          - "security-level {{ item.level }}"
          - "ip address {{ item.ip }}"
      loop: "{{ asa_ifaces }}"
      loop_control:
        label: "{{ item.name }}"

    - name: Read the shutdown state
      cisco.asa.asa_command:
        commands: "show running-config interface {{ item.name }}"
      loop: "{{ asa_ifaces }}"
      loop_control:
        label: "{{ item.name }}"
      register: ifrun

    - name: No shutdown, only where the running config says shutdown
      cisco.asa.asa_config:
        parents: "interface {{ item.item.name }}"
        lines: [no shutdown]
        match: none
      loop: "{{ ifrun.results }}"
      loop_control:
        label: "{{ item.item.name }}"
      when: item.stdout[0] is search("^ shutdown$", multiline=True)

On a clean rerun the last task skips both interfaces and the recap says changed=0. To prove the other branch, I shut Gi0/1 by hand and ran it again. Only Gi0/1 changed:

skipping: [hq-fw1] => (item=GigabitEthernet0/0)
changed: [hq-fw1] => (item=GigabitEthernet0/1)
hq-fw1                     : ok=3    changed=1    unreachable=0    failed=0    skipped=0

Object groups with asa_ogs

asa_ogs is a resource module, so it takes structured data instead of CLI lines and supports the same states as the IOS modules in part 3.

---
- name: Object groups
  hosts: firewalls
  gather_facts: false
  tasks:
    - name: Web servers and web ports
      cisco.asa.asa_ogs:
        state: merged
        config:
          - object_type: network
            object_groups:
              - name: WEB-SERVERS
                description: Public web servers in inside
                network_object:
                  host:
                    - 10.10.10.10
                    - 10.10.10.11
          - object_type: service
            object_groups:
              - name: WEB-PORTS
                protocol: tcp
                port_object:
                  - eq: www
                  - eq: https

--check -v shows the commands before anything is sent:

"commands": ["object-group network WEB-SERVERS", "description Public web servers in inside", "network-object host 10.10.10.10", "network-object host 10.10.10.11", "object-group service WEB-PORTS tcp", "port-object eq www", "port-object eq https"]

The real run returned changed=1 and the rerun changed=0.

The inbound ACL with asa_acls

The ACL references both groups by name, so it has to run after the objects exist. Binding it to the interface is a plain access-group line, which asa_acls doesn't manage.

---
- name: Inbound ACL
  hosts: firewalls
  gather_facts: false
  tasks:
    - name: OUTSIDE-IN
      cisco.asa.asa_acls:
        state: "{{ acl_state | default('merged') }}"
        config:
          acls:
            - name: OUTSIDE-IN
              acl_type: extended
              aces:
                - grant: permit
                  line: 1
                  protocol: tcp
                  source:
                    any: true
                  destination:
                    object_group: WEB-SERVERS
                    service_object_group: WEB-PORTS
                - grant: deny
                  line: 2
                  protocol_options:
                    ip: true
                  source:
                    any: true
                  destination:
                    any: true
                  log: default
    - name: Bind it to outside
      cisco.asa.asa_config:
        lines:
          - access-group OUTSIDE-IN in interface outside

Check mode:

"commands": ["access-list OUTSIDE-IN line 1 extended permit tcp any object-group WEB-SERVERS object-group WEB-PORTS", "access-list OUTSIDE-IN line 2 extended deny ip any any log default"]
"updates": ["access-group OUTSIDE-IN in interface outside"]

Apply gave changed=2, the rerun changed=0.

What overridden would have done

merged only adds. overridden makes the device match your list exactly, and my list has one ACL. In check mode:

ansible-playbook -i fw-inv playbooks/62-asa-acls.yml --check -v -e acl_state=overridden

"commands": ["no access-list MGMT_IN line 1 extended permit tcp any any eq ssh"]

That's the ACL bound to the management interface, the one that was there before I started. On this ASAv my SSH session would have survived, because traffic to the box itself is controlled by the ssh command and not by interface ACLs. On a real firewall that ACL is probably there for a reason someone else owns. Use overridden only when your playbook really holds every ACL on the box, and run it in check mode first every time.

The same goes for anything that can cut off your own session. None of these playbooks touch ssh, aaa, username, http or the management interface. If you do need to change those, have console access open first and change them in a separate play.

NAT with asa_config

cisco.asa 7.0.0 has no NAT resource module, so object NAT goes through asa_config with the object as the parent:

---
- name: NAT
  hosts: firewalls
  gather_facts: false
  tasks:
    - name: PAT for the inside subnet
      cisco.asa.asa_config:
        parents: object network INSIDE-NET
        lines:
          - subnet 10.10.10.0 255.255.255.0
          - nat (inside,outside) dynamic interface
    - name: Static NAT for web1
      cisco.asa.asa_config:
        parents: object network WEB1
        lines:
          - host 10.10.10.10
          - nat (inside,outside) static 203.0.113.10

Check mode showed exactly those six lines. Apply returned changed=2, and the rerun changed=0. I'd expected this one to be noisy, because show run prints the object definition and its nat line in separate blocks:

object network INSIDE-NET
 subnet 10.10.10.0 255.255.255.0
object network WEB1
 host 10.10.10.10
...
object network INSIDE-NET
 nat (inside,outside) dynamic interface
object network WEB1
 nat (inside,outside) static 203.0.113.10

It wasn't. asa_config matched both lines under each object.

Test it with traffic

From the outside host, HTTP to the static NAT address worked and a TCP port the ACL doesn't allow was dropped:

wget -q -T 5 -O - http://203.0.113.10/   # returned "web1", the inside listener
nc -w 5 -z 203.0.113.10 2222             # exit code 1, dropped by line 2

The hit counters agree. The object groups expand into four entries, and only www to 10.10.10.10 was hit:

access-list OUTSIDE-IN line 1 extended permit tcp any object-group WEB-SERVERS object-group WEB-PORTS (hitcnt=1)
  access-list OUTSIDE-IN line 1 extended permit tcp any host 10.10.10.10 eq www (hitcnt=1)
  access-list OUTSIDE-IN line 1 extended permit tcp any host 10.10.10.10 eq https (hitcnt=0)
  access-list OUTSIDE-IN line 1 extended permit tcp any host 10.10.10.11 eq www (hitcnt=0)
  access-list OUTSIDE-IN line 1 extended permit tcp any host 10.10.10.11 eq https (hitcnt=0)
access-list OUTSIDE-IN line 2 extended deny ip any any log default (hitcnt=5)
NAT from inside:10.10.10.10 to outside:203.0.113.10

Save only when something changed

Nothing above is in the startup config yet. A reload would undo all of it. save_when: modified compares running and startup and runs write memory only if they differ:

---
- name: Save only if the running config differs from startup
  hosts: firewalls
  gather_facts: false
  tasks:
    - name: Save
      cisco.asa.asa_config:
        save_when: modified
--check   changed: [hq-fw1] => {"changed": true}
run       changed: [hq-fw1] => {"changed": true}
rerun     ok: [hq-fw1] => {"changed": false}

Check mode reported a change but didn't save, which is why the real run still had something to do. After the save, show startup-config contained the new access-group, both access-list lines and both NAT objects. I keep the save as its own playbook run at the end, after I've looked at the verification output. If a change breaks something, a reload still gets you back to the last good config.

What I'd do on a production ASA

Put the firewall in its own inventory. Log in once by hand after a fresh build to clear the forced password change. Run every playbook with --check -v and read the commands. Rerun each one and expect changed=0; if you don't get it, find out why before you trust the recap, like the no shutdown case. Don't use overridden unless the playbook owns every ACL. Leave management access out of these playbooks. Save last, with save_when: modified, after you've tested traffic.

Next in the series: Ansible Gathered, Rendered and Parsed on Cisco IOS XE: Read State Without Changing Anything, reading device state into structured data without changing anything.

Read next