Wednesday, October 30, 2024

No REST for the RESTful: Python And The Horizon Server API

Expanding upon earlier articles in this No REST for the RESTful series, today's post explores Horizon automation using Python.  These previous articles detailed how to automate Horizon administration through a sequence of calls executed from Postman. With our logic and syntax for the Horizon Server API already sorted out via this prior work, the path forward to a Python script is relatively straight forward. We can adopt logic from Postman into Python using the Requests module, "an elegant and simple HTTP library for Python, built for human beings." 


The Requests module is an ideal mechanism for making HTTP requests from Python, providing an easy way to retrieve and process data from the Horizon Server API.  Adopting Postman logic into Python revolves around translating Postman requests to calls made from Python's Requests module.  My sample scripts are available here at GitHubThis article will provide a a quick primer on the Requests module that drives these scripts, then walk through some of the functions I've created to simplify interactions between Python and the Horizon Server API. 

Python's Requests Module 

The Requests module is a widely adopted and well documented mechanism for making HTTP requests from Python.  It allows you to make requests with as little as a single line of code.  Further, with simple to use parameters for building complex requests, along with features like it's JSON decoder, the requests module makes it easy to retrieve and parse data provided from a REST API. 

https://requests.readthedocs.io/en/latest/







Once you have the request module installed, making HTTP requests is a snap.  To make a simple request against GitHub, you can type these two lines within the Python interpreter: 

import requests
r = requests.get('https://api.github.com/events')


The response is stored in an object called r you can start to pull information from.  For example, to confirm the status of the call you can leverage the status_code attribute of the r object. 

print(r.status_code) 

Or, to get the JSON response body of the request you can leverage the JSON decoder:

print(r.json()) 

For Python interactions with the Horizon Server API I lean heavily on the GET and POST methods of the Requests module.  The more complex calls are made using Requests's parameters like params, headers and JSON.  These parameters allowed me to replicate the calls made from the Horizon Server API Shenanigans Postman collection.  

https://requests.readthedocs.io/en/latest/api/

To simplify the adoption of Horizon Server Shenanigans to Python I've created four different functions. The remainder of this article will review how these functions help automate tasks against the Horizon Server API.  


Handling Authentication With The get_access_token Function

All the sample Python scripts lean on a function for handling authentication/authorization called get_access_token.  This function replicates the login call used in the Horizon Server API Shenanigans Postman collection.  For context, here's what the Login request looks like in Postman:









To replicate this process in Python the get_access_token function gathers the ingredients required to make an identical call using the Requests module.  It's first argument, the target variable, is the full address to the login endpoint in the target Horizon environment.  The following 3 variables are the AD login credentials for an admin account in the Horizon environment.  These make their way into the JSON request body through the Requests's module JSON parameter.  

def get_access_token(target, username, password, domain):
    json_data = {
    "username": username,
    "password": password,
    "domain": domain
    }

    response = requests.post(
        target,
        json=json_data,
        verify=True
    )

    return response.json()["access_token"]

If all goes well the request module will return the request response back in the response object.  We can then use the Requests module's .json() decoder method to get the desired JSON value of access_token. This access token is an absolute requirement for any other calls to the Horizon Server API.  All the scripts start off by obtaining the access token with this function and assigning it to the variable JWtoken:

JWtoken = get_access_token(target, username, password, domain)

This JWtoken variable is then referenced by every other function that makes calls to the Horizon Server API, inserted in the headers of these calls: 

headers = {
    'authorization': 'Bearer ' + JWtoken
}


So the successful execution of this get_access_token function is absolutely required for any of the other functions to work.   


Filtering Out JSON Responses With getR_got_w_filter

A critical requirement for these scripts is to leverage filter query parameters to narrow down JSON response results. Calls to endpoints like external/v1/ad-users-or-groups can yield thousands of results depending on the size of a production environment.  Fortunately, many Horizon Server API endpoints support filtering and we can access this capability from Python scripts in a similar manner we've done already with Postman.  If you haven't already read No REST for The RESTful: Chaining Together Calls To The Horizon Server API, halfway through the article I provide a primer of filter query parameters.  

One of the most prominent uses of filtering within the Horizon Server API Shenanigans Postman collection is to narrow down a JSON array response of AD users to a single user.   Here's an example from that collection:







Yes, leveraging filter objects leads to some messy visuals in Postman, but well worth it when you consider the benefits. Thankfully, it's little less messy to use filters with Python's Requests module.   To begin with, here's what the function looks like:

def getR_got_api_w_filter(target, filter_param):
    headers = {
        'authorization': 'Bearer ' + JWtoken
    }

    params = {
        'filter': filter_param
    }

    response = requests.get(
        target,
        params=params,
        headers=headers,
        verify=True,
    )

    return response.json()


Fortunately, creating the filter object for use with the Requests module is a little less involved.  Instead of having to minify AND encode the object filter - see ugly Postman screenshot above - we only have to minify the object filter.  The Requests object handles the encoding for us by default.  So, when creating the filter object argument for getR_got_w_filter, the scripts have this relatively simple line of code: 

ad_users_or_groups_filter = '{"type":"Equals","name":"login_name","value":"' + login_name + '"}'

This add_user_or_groups variable is then passed on as an argument to getR_got_w_filter, along with a URL to the target endpoint:

responseBodyJSON = getR_got_api_w_filter(target_url, ad_users_or_groups_filter)

If all goes well we're returned a single JSON object within an array.  We can grab the internal Horizon ID from this object and assign it to target_user_id.   

target_user_id = responseBodyJSON[0]["id"]

Translating a short AD login name to this internal ID used by Horizon is a task performed in all 4 sample scripts. A lot of the more interesting tasks to address with the Horizon Server API require this process.


Making Posts To The Horizon Server API Using getR_posted_w_JSON

The culmination of these sample Horizon automations is a Post request of some sort.  All four of the automations start with the gathering of prerequisite information that eventually finds it's way in a climatic Post request.  Typically, these Post methods with the Horizon Server API require a JSON body request with specific parameters depending on the endpoint being called.  For example, there's this request that sends a message to an active Horizon session:








Calls like these are simplified within my Python scripts using the gerR_posted_w_JSON function. It crafts a POST request by taking in a target URL and JSON body as an argument.   Here's the function:

def getR_posted_w_JSON(target, json_4_request):
    headers = {
        'authorization': 'Bearer ' + JWtoken
    }

    response = requests.post(
        target,
        json=json_4_request,
        headers=headers,
        verify=True
    )

    return response

Note that, unlike the previous function, in this one the Requests module leverages the POST method, request.post,  when making it's call.   It's critical 2nd argument is a JSON body response that does most of the heavy lifting.  For example, in the Message_N_Disconnect.py script, the JSON body is delivered via a json_data variable configured like this:   

json_data = {
    "message": "Your session will be disconnected in about 8 seconds.",
    "message_type": "INFO",
    "session_ids": [ target_session_id ]
}

If you look back at the Postman screenshot directly above this variable configuration should look familiar.  

While this getR_posted_w_json method is simple enough, it plays a star role in 3 of the 4 Python scripts.

 

Searching Through Returned Arrays Of JSON Objects With getR_got_w_object_search

No all endpoints of the Horizon Server API support filters.  In those situations there's sometimes a need to do some filtering on your own by looping through an array of objects in search of a key with a unique value.  The Kill Specific App In Session automation is a great example.  The last call in this sequence returns an array of JSON objects, with each object representing a separate application actively running within a Horizon session. To narrow in on the target app within this array we can loop through each object, checking for a match between one of the objects keys called, "name," that has a value matching the target application we wish to terminate. Within Postman we accomplished this using a forEach method against the JSON response data.  If you look at the post-response script used by this call you'll see the JavaScript in action: 









To adapt this Postman request to Python I came up with the getR_got_w_object_search.  Note the foreach function leveraged at the tail end of this function.  It mimics the JavaScript logic above, looping through the entire array in search of an object with a specific key holding a searched for unique value.  Take a look:

def getR_got_w_object_search(target, params, object_key_name, object_key_value):
    headers = {
        'authorization': 'Bearer ' + JWtoken
    }

    response = requests.get(
        target,
        params=params,
        headers=headers,
        verify=True
    )

    object_array = response.json()

    for json_object in object_array:
        if json_object[ object_key_name ] == object_key_value:
            return json_object


With this function there are 4 required arguments, two of which specify the name and value to search against.   The object_key_name variable specifies the specific key of each object to search against, while object_key_value represents a value to search for.   For example, in the Python adaptation of the Kill Specific App In Session automation, the object_key_name is, "name," and the value getting searched for is, "Calculator."  When an object with the value of, "Calculator," for it's name is located, it's returned by the function.  

application_object = getR_got_w_object_search(target, params, "name", target_app)

Then the value of it's remote_application_id is pulled from the application_object returned from getR_got_w_object.

remote_app_id = application_object[ "remote_application_id" ]

This remote_app_id is then leveraged later on in a call to the end-remote-application endpoint.  

The ability to loop through an array of objects like this comes up several times throughout the four automations.  It's a requirement that's bound to show up when an endpoint doesn't support filtering or there's a situation when multiple objects need to be extracted from a response.  


Communicating Securely With The REST API

By default, Python's Requests module leverages the same trusted root authorities as the Mozilla browser.  Specifically, it uses a bundle of trusted CA certificates from the certify library, which uses a curated collection of Root Certificates from Mozilla. So if you have a publicly trusted SSL on your Connection server there's a good chance you'll be fine running requests against it.  Quite often though customers user an SSL cert issued from their on internal certificate authority, not a publicly trusted authority.  In those situations the Requests module will fail to make a secure connection and these scripts will fail.  To get around that challenge, there's two options.  The first one is rather easy and suitable if you're working within a lab environment.   You can just set Verify to false when making the requests.   For example:

    response = requests.post(
        target,
        json=json_data,
        verify=True
    )

This will disable SSL verification and allow the request to be made against an untrusted URL.   That's certainly fine for a lab environment, but isn't suitable for a production deployment.  To make secure requests against a Connection server using a SSL cert from an internal CA, you can instruct the Requests module to trust the internal certificate authority by specifying a path to a custom CA bundle to use.   In my environment I've pointed the requests module to an export of my internal certificate authority.  

    response = requests.post(
        target,
        json=json_data,
        verify="root_cert.cer"
    )

While it's easier to just set verify to false and move on to the automation fun, given you're sending AD credentials over these connections, in any situations outside a lab environment you'll need to get this sorted out properly. 

What About Error Handling? 

These sample Python scripts are designed to demonstrate bare bones functionality, so they're missing a few features required for a production environment.  Specifically, there's no error handling.  For example if you run the Message_N_Disconnect.py and the target user doesn't have an active Horizon session, the script just breaks with no useful output.  Same goes for the Kill_App_In_Session.py script.  If the target app isn't running, the script just breaks with no useful output.   The main intent of these scripts is to demonstrate as simply as possible how Python can be leveraged for the Horizon Server API, a MVP for educational purposes.   If someone intends to leverage these for production they are going to need to add some tweaks for error handling and flow control.  In the future I might update these scripts accordingly, but dadda's tired and needs a rest. 

If you want to see an example of Python scripts for the Horizon Server API that are more fine tuned for production, check out the Python module developed by Wouter Kursten.  If you look through his code you'll see he has some pretty extensive error handling in place.  You might also notice he's leaning on the Requests module to do a lot of the heavy lifting. You can view his demonstration in this VMworld 2021 video


Conclusion

My primary motivation for writing this overview is that Python is currently the programing language I'm most comfortable with.   About a year ago I leveraged Python to create troubleshoot.evengooder.com, so it's relatively fresh in my head and easy to work with.  That said, I'm no expert. Simply a geek that used to get quite frisky with VBScript in the oughts and now has developed some familiarity with Python.  So, this article isn't intended to be a tutorial on elegant Python programing.  It's a demonstration of how we can easily pivot to whatever language or mechanisms we like once we've gotten our Horizon Server API syntax down.  That's the beauty of the Horizon Server API's  REST based architecture: folks can use their programing language of choice.