> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Ansible for Cisco ASA: Object Groups, ACLs and NAT Without Locking Yourself Out
- URL: https://www.pinglabz.com/ansible-cisco-asa-acl-nat/
- Published: 2026-10-31T19:36:00.000Z
- Updated: 2026-10-31T19:59:25.000Z
- Description: 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.
- Author: Jaime
- Tags: Network Automation, cisco-asa, Cisco

This is part 7 of the [Ansible for network engineers series](https://www.pinglabz.com/ansible-network-automation/). 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.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/ansible-asa-acl-nat-topology.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/ansible-asa-acl-nat-topology.png?ref=pinglabz.com)

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.

```yaml
# fw-inv/hosts.yml
all:
  children:
    firewalls:
      hosts:
        hq-fw1: {ansible_host: 10.66.10.41}

```

```yaml
# 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](https://www.pinglabz.com/ansible-network-inventory-site-role/). In the post they're placeholders:

```yaml
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:

```text
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](https://www.pinglabz.com/ansible-network-troubleshooting-casebook/)), 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:

```text
"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`:

```text
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:

```text
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`.

```yaml
---
- 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:

```text
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](https://www.pinglabz.com/ansible-resource-module-states-merged-replaced-overridden-deleted/).

```yaml
---
- 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:

```text
"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.

```yaml
---
- 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:

```text
"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:

```text
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:

```yaml
---
- 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:

```text
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:

```bash
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:

```text
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:

```yaml
---
- 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

```

```text
--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](https://www.pinglabz.com/ansible-gathered-rendered-parsed-ios/), reading device state into structured data without changing anything.