Automating VPN Client Quarantine with Ansible & Radius CoA Scenario

"The What?" - In this blog I want to share a mini automation project that will allow you to rely on Ansible & ISE to automate a quarantine workflow. My goal here is not to cover how to setup Ansible & ISE ERS, but to provide an example of one way to automate a unique quarantine workflow by providing an example that will hopefully help others by providing them with my example.


"The Why?" - Being able to automate a quarantine procedure utilizing the components mentioned in this blog will increase security & reaction time in regard to moving a client immediately to a quarantine network. Yes, there is manual intervention for this to occur so it is not fully automated in a zero trust type fashion. However, if you are like me, I think streamlining workflows to make things simple & effective for admins to take action with is a huge benefit in any environment.


"The How?" - There are several components in this setup. The components are Ansible Tower, ISE + ISE APIs, networks to support quarantine & network devices configured to support radius CoA. I will not be covering each individual component from a configuration perspective so it is assumed we have basic knowledge of things such as: AnyConnect RAVPN setups, ISE radius policy config, ansible basics, python, & API basics. However, quick note, for anyone seeking additional information on Ansible check out my other posts via the <ansible> tag and/or Automation tab. Also, for ISE ERS have a look here: Enabling ISE 3.0 ERS APIs.


The important thing to understand here is that there are many ways to quarantine clients and preferences/environments will vary. I am only sharing this as one option to provide some insight on how to automate the workflow with Ansible and API consumption. My goal is to not only share the idea/example, but provide code snippets that hopefully will help others in their journey. Ok so, the workflow allows several things:

  • Phase 1 - Manipulate ISE DB via API to statically assign MAC address to ISE endpoint group

  • Phase 2 - Consume another ISE API to issue Change of Authorization on current client session you are wishing to immediately act on to move into quarantine state

  • Phase 3 - Retrieve updated information from ISE to verify static quarantine group assignment

  • Phase 4 - Retrieve updated client session from ISE API via accounting session details

Tower workflow:

Don't get hung up on the names depicted. Just sharing an overview of the tower workflow. The names can be changed to whatever. Note there is a user survey that collects user defined variables that are referenced in some of the yaml code. The user variables collected are mac address and description. This way ansible knows what client mac it is working with for the workflow, and a description so that the client within ISE will have some sort of description, perhaps why the client was quarantined (this is good for admins at a later date to remember why :) ).


Breaking down each phase further, let's start with phase 1. Note that in this scenario the quarantining is done via radius policies that references an ISE endpoint group which is made up of a collection of client MAC address. Note: using the radius live logs presents these types of options available and is always a useful tool.


Now since that is clear, phase 1 consumes an Endpoint ISE API which allows us to use REST to update a user defined/selected client MAC via static assignment to our quarantine endpoint identity group, which is referenced in our VPN ISE radius policies used during onboarding.


The condition used looks something like this:


Phase 2 allows us to consume an ISE MnT API to manage the current session of our remote endpoint that we have deemed vulnerable for whatever reason. We specifically terminate the current/active session via CoA. This CoA is triggered from our Ansible workflow only after successful phase 1 completion. The CoA is sent from our ISE cluster to the VPN concentrator, in this case an ASA. These types of details can be seen via radius live log details, example:


The ASA then terminates the session. Omitting AnyConnect configuration used in this scenario, but due to AnyConnect profile settings clients are setup for Always On so upon CoA disconnect the remote client attempts connection again. At this point the ASA passes the new onboarding attempt to ISE which parses the VPN radius policies in which it will determine that this second (new) connection is from a client that matches the policy referencing the quarantine endpoint group all thanks to phase 1.


At this point ISE pushes authz profile results which contains a radius attribute which tells the ASA what ACL to apply to new quarantine session restricting network access, and placing the client in quarantine. ASA log viewer showing received CoA from ISE:

Combining phases 3 & 4 since they are primarily used for Ansible user verification to get the warm & fuzzies of successfully quarantining a client like they set out to do. Phases 1 & 2 do the heavy lifting in this workflow example. So phase 3 retrieves client attributes from ISE which essentially allows the admin to verify the static group assignment. Phase 4 allows us to consume an ISE API to determine what the "new" client session details are. I use a custom ansible module to consume the API and parse returned xml data to then extract relevant information that will help the ansible admin collect some client information for perhaps reporting purposes.


Now that the 4 phases of the workflow have been dug into, let's take a look at each individual playbook so you have an understanding of what is happening under the hood.


Phase 1 playbook - VPN Client Quarantine: NOTE: update url and ISE user/pass

---
- name: ISE
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    ise_user: xxxx
    ise_pass: xxxx

  tasks:
  - name: Get Existing Endpoint ID string from ISE
    uri:
      url: https://xx.xx.xx.xx:9060/ers/config/endpoint?filter=mac.EQ.{{ mac_addr }}
      user: "{{ ise_user }}"
      password: "{{ ise_pass }}"
      headers:
        Accept: application/json
        content-type: application/json
        ers-media-type: identity.endpoint.1.2
      status_code: 200
      method: GET
      validate_certs: no
    register: endpoint_id

  - name: Print returned ISE json data
    debug:
     msg: "{{ endpoint_id.json }}"
   
  - name: Get ISE ID String
    set_fact:
     id: "{{ endpoint_id | json_query(jmesquery) }}"
    vars:
      jmesquery: '*.SearchResult.resources[*].id'

  - name: Extract ID from Nested List
    set_fact:
     id: "{{ id[0][0] }}"

  - name: Print ISE Endpoint ID
    debug: 
     msg: "{{ id }}"

  - name: Move existing MAC to new Identity Group
    uri:
      url: https://xx.xx.xx.xx:9060/ers/config/endpoint/{{ id }}
      user: "{{ ise_user }}"
      password: "{{ ise_pass }}"
      headers:
        Accept: application/json
        content-type: application/json
        ers-media-type: identity.endpoint.1.2
      status_code: 200
      method: PUT
      body_format: json
      body: '{"ERSEndPoint" : {"staticGroupAssignment" : "true","description" : "{{ desc }}","groupId" : "eexxc0-b679-11ec-b9d6-5eafxxxxx45"}}'
      validate_certs: no

  - name: Get Updated Endpoint Details
    uri:
      url: https://xx.xx.xx.xx:9060/ers/config/endpoint/{{ id }}
      user: "{{ ise_user }}"
      password: "{{ ise_pass }}"
      headers:
        Accept: application/json
        content-type: application/json
        ers-media-type: identity.endpoint.1.2
      status_code: 200
      method: GET
      validate_certs: no
    register: endpoint_details

  - name: Get ISE Group ID String
    set_fact:
     id_details: "{{ endpoint_details | json_query(jmesquery) }}"
    vars:
      jmesquery: '*.ERSEndPoint.groupId'

  - name: Extract Group ID from Nested List
    set_fact:
     group_id_string: "{{ id_details[0] }}"

  - name: Print ISE Endpoint Group ID
    debug: 
     msg: "{{ group_id_string }}"

  - name: Get Endpoint Group Assignment
    uri:
      url: https://xx.xx.xx.xx:9060/ers/config/endpointgroup/{{ group_id_string }}
      user: "{{ ise_user }}"
      password: "{{ ise_pass }}"
      headers:
        Accept: application/json
        content-type: application/json
        ers-media-type: identity.endpointgroup.1.1
      status_code: 200
      method: GET
      validate_certs: no
    register: endpoint_group

  - name: Print returned ISE json data
    debug:
     msg: "{{ endpoint_group.json }}"

  - name: Get ISE Group Name from ISE json data
    set_fact:
     group_name: "{{ endpoint_group | json_query(jmesquery) }}"
    vars:
      jmesquery: '*.EndPointGroup.name'

  - name: Extract Group Name from Nested List
    set_fact:
     name: "{{ group_name[0] }}"

  - name: Print returned ISE group name
    debug:
     msg: 'The Endpoint now belongs to the following group in ISE: "{{ name }}"'

Phase 2 playbook - CoA Term Session - custom ansible module so I can use python:

--- 
name: ISE Module CoA Session Termination  
hosts: localhost  
gather_facts: False  
tasks:    
  - name: Force Terminate Session via CoA      
    coa_term_sess:        
      mac_addr: "{{ mac_addr }}"

coa_term_sess.py code snippet acting as our ansible module; Note: update url to meet your environment needs:

#!/usr/bin/python
import requests
import xml.etree.ElementTree as ET

from ansible.module_utils.basic import AnsibleModule

def main():
    # Define your module arguments
    module_args = dict(
        mac_addr=dict(type="str", required=True),
    )

    # Create an instance of AnsibleModule class
    module = AnsibleModule(argument_spec=module_args)
    result = dict(changed=False, msg="")

    # Extract the arguments
    mac_addr = module.params["mac_addr"]

    API_DEVICE = "https://x.x.x.x/admin/API/mnt/CoA/Disconnect/PSNx.x.x.x/" + mac_addr + "/0/"
    API_ERS_USER = "xxx","xxx"

    r = requests.get(url=API_DEVICE, auth=API_ERS_USER, verify=False)

    tree = ET.fromstring(r.content)

    ise_data = {
        "ISE Change of Authorization Result": tree.findtext('results'),
}
    result["msg"] = ise_data
    module.exit_json(**result)

if __name__ == "__main__":
    main()

Phase 3 playbook - Get SDA Endpoint Details (I reuse an existing job template, don't get caught up on naming convention so I am excluding the playbook. If you look at phase 1 playbook & start from task 'Get Updated Endpoint Details' you can work with that downward which will achieve what we want here - retrieve client static group assignment details again;


Phase 4 - ISE - Get VPN Client Details: Another custom module to utilize python to handle/manipulate the returned xml data:

---
name: ISE Custom Module  
hosts: localhost  
gather_facts: False  
tasks:    
  - name: Getting VPN Client Detail from ISE      
    ans_mod_ise_vpn_client_detail:        
      mac_addr: "{{ mac_addr }}"

Python code for module: note - update ISE url, user/pass, check formatting:

#!/usr/bin/python
import requests
import xml.etree.ElementTree as ET

from ansible.module_utils.basic import AnsibleModule

def main():
    # Define your module arguments
    module_args = dict(
        mac_addr=dict(type="str", required=True),
    )

    # Create an instance of AnsibleModule class
    module = AnsibleModule(argument_spec=module_args)
    result = dict(changed=False, msg="")

    # Extract the arguments
    mac_addr = module.params["mac_addr"]

    API_DEVICE = "https://xx.xx.xx.xx/admin/API/mnt/Session/MACAddress/" + mac_addr
    API_ERS_USER = "user","pass"

    r = requests.get(url=API_DEVICE, auth=API_ERS_USER, verify=False)

    tree = ET.fromstring(r.content)

    ise_data = {
        "Endpoint public IP Address": tree.findtext('orig_calling_station_id'),
        "Endpoint VPN IP address" : tree.findtext('framed_ip_address'),
        "Last user to login to client" : tree.findtext('other_attr_string'),
        "Client Posture Status" : tree.findtext('posture_status'),
        "Auth Timestamp of most recent session" : tree.findtext('auth_acsview_timestamp'),
}
    result["msg"] = ise_data
    module.exit_json(**result)

if __name__ == "__main__":
    main()

Phase 3 Tower successful execution example showing what ansible admin would see from consuming ISE API to verify that the client is a member of quarantine endpoint identity group:


Phase 4 Tower successful execution example showing what ansible admin would see from consuming ISE API - Note in this workflow example that ISE posture assessment is in play, & the client can still be deemed compliant via ISE posture assessment, but be placed in quarantine via this workflow so don't get confused with the 'Client Posture Status' result. This is shared to provide additional information to the admin. Tweaking the code above for this phase would allow you to remove certain xml data retrieved/extracted.


That about wraps this up, I hope this shared automation workflow example helps others with their adventures. I would recommend utilizing curl to test/play with the APIs. Make sure to test in a non-production environment. Lastly, tweak the respective variables, etc. to meet your needs. Good luck & Cheers!



0 comments

Recent Posts

See All

"The What?" - In this blog I want to cover a project with Ansible that I created to automate parts of a workflow relating to an SDA edge node (EN) deployment. Now to breakdown the workflow I will be

"The What?" - In this blog I want to cover an example of using Ansible to automate the process of provisioning ports based on user defined variables & ansible facts across different ios-xe platform ty

"The What?" - In this blog I want to cover a brief overview of one way to install AnyConnect (AC) on a linux client running a supported OS. Once I cover the overview I intend on covering a few Ansibl