ResetCellularPlan MDM action with the Jamf Pro API

Their are a lot of flavors to the different MDM command endpoints. There are lots of things you can do and lots of ways to access them. Many commands can be sent to a device or a list of devices with just a URL, some require that you send some additional data in an HTTP post body.

We’re using resetCellular here, but it’s just one example of the kinds of things you can push out and I’m only showing how to send it to a single device.

The only requirement is that you have some eSIM devices and an API user with the correct permissions. The permissions needed to make an API call aren’t always obvious but you sure don’t want to use any user with API stuff that has more permissions that strictly required. It’s good security practice and will save you heartache if you mess up. Test test test.

Permissions needed for API endpoints are documented here… https://developer.jamf.com/jamf-pro/docs/classic-api-minimum-required-privileges-and-endpoint-mapping. It states that in order to create MDM commands, we’ll need “Send Mobile Device Command” as well as Create on Mobile Devices. 

/mobiledevicecommands/command…POSTCreate” on Mobile Devices

Send Mobile Device [commandName] ” under Jamf Pro Server Actions

This script is in both bash and Python. They’re not complete solutions, just demos so you can see how things work. We’re going to address devices by serial number since that’s what you’re working with when setting up batches of cellular iPads, usually.

Notice that first we send the resetCellular request and the API responds with a command ID. But that just tells us that Jamf got the message, not that the whole MDM/APNs conversation actually happened with the device, Jamf, and Apple. So to find that out, we take the command ID we got back from the API and do a get on it. That will provide more info, including the APNs status.

#!/bin/bash

# Given a serial number, send a reset cellular plan command.  

# Don't forget you can send the reset cellular plan to groups of devices with 
# mass-actions right in the Jamf Pro GUI. You may not need to script anything. 

# In the real world you might read a list of serial numbers for new iPads and 
# loop through them, or you might send a command to all members of a smart or 
# static group. You don't have to do one device at a time. You can also build
# up the XML for posting an MDM command to lots of devices at once.  


jamf_url="https://your.jamfcloud.com"
userpass="username:password"
device_serial_number='ZZZZ11YYYYY9'

# Do you want to stop after telling Jamf Pro to send the command or wait a 
# while to see if APNs confirmed delivery? If you have a cart full of on-line 
# iPads in front of you, true might be nice. If you have no idea if the device
# is even on, maybe don't bother. 
check_command_apns_status=true

# Check with your carrier for the right URL. 
e_sim_server_url='https://t-mobile.gdsb.net'  # T-Mobile
# e_sim_server_url='https://2.vzw.otgeuicc.com'  # Verizon	
# e_sim_server_url='https://cust-001-v4-prod-atl2.gdsb.net'  # AT&T


sendCommand () {
  # Sends a mobile device cellular plan reset command
  local data="<mobile_device_command>
    <general>
      <command>RefreshCellularPlans</command>
      <e_sim_server_url>${e_sim_server_url}</e_sim_server_url>
    </general>
    <mobile_devices>
      <mobile_device>
        <serial_number>${device_serial_number}</serial_number>
      </mobile_device>
    </mobile_devices>
  </mobile_device_command>"

  local url="${jamf_url}/JSSResource/mobiledevicecommands/command"

  local response=$( curl \
    --user "${userpass}" \
    --header "Content-Type: application/xml" \
    --header 'Accept: application/xml' \
    --write-out $'\n%{http_code}' \
    --request POST  \
    --data "${data}" \
    --show-error \
    --silent \
    "${url}" )

    echo "${response}"
}


processResponse () {
  # Pulls information about the mobile device command response
  #  and checks if the command went through
  
  local url="${jamf_url}/JSSResource/mobiledevicecommands/uuid/${command_uuid}"
  local response=$( curl \
    --user "${userpass}" \
    --header 'Accept: application/xml' \
    --write-out $'\n%{http_code}' \
    --request GET  \
    --data "${data}" \
    --show-error \
    --silent \
    "${url}" )

  echo "${response}"
}



echo "[start] Processing serial number ${device_serial_number}"
echo "[Step 1] Sending refresh cellular plan command"
response=$( sendCommand )

http_code=$( echo "$response" | tail -1)
response=$( echo "$response" | sed \$d )

# An error response will throw an HTTP 400 with an html error message. 
# A successful response will return an HTTP 201 and response XML like this...
#   <?xml version="1.0" encoding="UTF-8"?>
#   <mobile_device_command>
#     <uuid>7fd4b969-6fcf-4ac7-9016-d7878f7742c3</uuid>
#     <command>RefreshCellularPlans</command>
#     <mobile_devices>
#       <mobile_device>
#         <id>12</id>
#         <management_id>549c8e89-d215-4a2e-806b-f054eb9c8430</management_id>
#         <status/>
#       </mobile_device>
#     </mobile_devices>
#   </mobile_device_command>

if [[ "${response}" == *'Device does not support the RefreshCellularPlan command'* ]]; then
  echo "[ERROR] Device \"${device_serial_number}\" does not support the RefreshCellularPlan command (${http_code})"
  exit 1
elif [[ "${response}" == *'Unable to match mobile_device'* ]]; then
  echo "[ERROR] Serial Number \"${device_serial_number}\" was not found in Jamf Pro (${http_code})"
  exit 1
elif [[ "${http_code}" == '201' ]]; then
  echo "[OK] Reset Celluar command has been sent to Jamf Pro (${http_code})"
  
  xpath_command_uuid='/mobile_device_command/uuid/text()'
  command_uuid=$( echo "${response}" | xmllint --xpath "${xpath_command_uuid}" - )
  echo "[info] Jamf Command UUID : ${command_uuid}"
  xpath_jamf_device_id='/mobile_device_command/mobile_devices/mobile_device/id/text()'
  jamf_device_id=$( echo "${response}" | xmllint --xpath "${xpath_jamf_device_id}" - )
  echo "[info] Jamf Device URL : ${jamf_url}/mobileDevices.html?id=${jamf_device_id}"
  
  # We could stop here. But instead of printing all that, some people like 
  # to do a delay loop where they keep calling that GET on the command UUID
  # until apns_result_status has a value, then stop and print that. You have
  # to wait a while for the command to go through APNs, though. And if the 
  # device is offline this will just time out. 

  if [ "${check_command_apns_status}" = true ] ; then

      echo; echo "[Step 2] Checking APNs Status..."
      apns_result_status=''
      max_time=60   # in seconds
      loop_delay=5  # recheck to see if the command went through how often? (in seconds)
      time_used=0
      xpath_apns_result_status='/mobile_device_command/general/apns_result_status/text()'
      while [[ ${time_used} -lt ${max_time} ]] && [[ -z ${apns_result_status} ]]; do
        sleep ${loop_delay}
        echo "[check] APNs Status Query (${time_used})"
        response2=$( processResponse )
        http_code=$( echo "$response2" | tail -1)
        response2=$( echo "$response2" | sed \$d )
        # echo "[info] APNs status request HTTP Code : ${http_code}"
        # echo "[info] APNs status request Response : ${response}"
        apns_result_status=$( echo "${response2}" | xmllint --xpath "${xpath_apns_result_status}" - )
        time_used=$((time_used+${loop_delay}))
      done
      
      echo "APNS Result Status : ${apns_result_status}"
  
      # A successful request for command status will look like this.
      # Once the command goes through you'll get apns_result_status = "Acknowledged"
      # <mobile_device_command>
      #   <general>
      #     <command>RefreshCellularPlans</command>
      #     <uuid>858cabb7-3267-45fa-bb9a-277a29bf62be</uuid>
      #     <profile_id>-1</profile_id>
      #     <e_sim_server_url>https://t-mobile.gdsb.net</e_sim_server_url>
      #     <date_sent>2021-11-18 06:10:21</date_sent>
      #     <date_sent_epoch>1637215821488</date_sent_epoch>
      #     <date_sent_utc>2021-11-18T06:10:21.488+0000</date_sent_utc>
      #     <apns_result_status/>
      #     <profile_udid/>
      #   </general>
      #   <mobile_devices>
      #     <size>1</size>
      #     <mobile_device>
      #       <id>12</id>
      #       <management_id>549c8e89-d215-4a2e-806b-f054eb9c8430</management_id>
      #       <udid>00008020-001251EE2193802E</udid>
      #       <serial_number>DMPD11NXLMV7</serial_number>
      #       <phone_number>19526524218</phone_number>
      #       <wifi_mac_address>34:A8:EB:03:89:08</wifi_mac_address>
      #     </mobile_device>
      #   </mobile_devices>
      # </mobile_device_command>
  fi


else
  echo "[ERROR] HTTP Code : ${http_code}"
  echo "[ERROR] API Response : ${response}"
  exit 1
fi

And Python…

import logging
import os
import time
import requests
import xml.etree.ElementTree as eTree


"""
Given a serial number, send a reset cellular plan command.  

Don't forget you can send the reset cellular plan to groups of devices with 
mass-actions right in the Jamf Pro GUI. You may not need to script anything. 

In the real world you might read a list of serial numbers for new iPads and 
send the command to all of them at once, or you might send a command to all
members of a smart or static group. 

You don't have to do one device at a time. You can build up the XML for 
posting an MDM command to lots of devices at once.  
"""

jamf_url = "https://your.jamfcloud.com"
device_serial_number = 'ZZZZ11ZZZZZ9'
carrier = 'T-Mobile'  # 'T-Mobile' | 'Verizon' | 'AT&T'

# Do you want to stop after telling Jamf Pro to send the command or wait a
# while to see if APNs confirmed delivery? If you have a cart full of on-line
# iPads in front of you, true might be nice. If you have no idea if the device
# is even on, maybe don't bother.
check_command_apns_status = True

# END OF CONFIGURATION VARIABLES

# Avoid putting passwords right in your scripts.
# Better to create environment vars and read them from there.
jamf_username = os.getenv('jamf_api_username')
jamf_password = os.getenv('jamf_api_password')
assert jamf_username and jamf_password

# Check with your carrier for the right URL.
carrier_urls = {'T-Mobile': 'https://t-mobile.gdsb.net',
                'Verizon': 'https://2.vzw.otgeuicc.com',
                'AT&T': 'https://cust-001-v4-prod-atl2.gdsb.net'}
e_sim_server_url = carrier_urls[carrier]  # Selects the url for the carrier you requested

# Setup logging
log_format = '%(asctime)s [%(levelname)-8s] %(message)s [%(filename)s:%(lineno)s - %(funcName)s()]'
log_date_format = '%Y-%m-%d %H:%M:%S'
level = logging.DEBUG  # level = logging.INFO
logging.basicConfig(format=log_format, datefmt=log_date_format, level=level)


def send_command():
    logging.info(f"Sending a mobile device cellular plan reset command.")
    data = f"""<mobile_device_command>
        <general>
          <command>RefreshCellularPlans</command>
          <e_sim_server_url>{e_sim_server_url}</e_sim_server_url>
        </general>
        <mobile_devices>
          <mobile_device>
            <serial_number>{device_serial_number}</serial_number>
          </mobile_device>
        </mobile_devices>
      </mobile_device_command>"""
    url = f"{jamf_url}/JSSResource/mobiledevicecommands/command"
    headers = {'Accept': 'application/xml', 'Content-Type': 'application/xml'}
    r = requests.post(url=url, headers=headers, data=data, auth=(jamf_username, jamf_password))

    logging.info(f"[info] Response Status : \"{r.status_code}\" (201 expected)")

    # Success will have HTTP status code = 201.
    # An error will throw a 400 and the response body will be and html-formatted error message.
    if r.status_code != 201:
        if 'Device does not support the RefreshCellularPlan command' in r.text:
            raise SystemExit(f"[ERROR] Serial Number \"{device_serial_number}\" does "
                             f"not support the RefreshCellularPlan command ({r.status_code})")
        elif 'Unable to match mobile_device' in r.text:
            raise SystemExit(f"[ERROR] Serial Number \"{device_serial_number}\" "
                             f"as not found in Jamf Pro  ({r.status_code})")
        else:
            html_clean = "\n".join([line.rstrip() for line in response.text.splitlines() if line.strip()])
            raise SystemExit(f"[ERROR] response Text : {html_clean}")

    """A successful response will send XML like this...
      <mobile_device_command>
        <uuid>7fd4b969-6fcf-4ac7-9016-d7878f7742c3</uuid>
        <command>RefreshCellularPlans</command>
        <mobile_devices>
          <mobile_device>
            <id>12</id>
            <management_id>549c8e89-d215-4a2e-806b-f054eb9c8430</management_id>
            <status/>
          </mobile_device>
        </mobile_devices>
      </mobile_device_command>"""

    logging.info(f"[OK] Reset Cellular command has been sent to Jamf Pro")
    return r


def process_response():
    logging.info(f"Checking if the device acknowledged the command")
    url = f"{jamf_url}/JSSResource/mobiledevicecommands/uuid/{command_uuid}"
    headers = {'Accept': 'application/json', 'Content-Type': 'application/xml'}
    r = requests.get(url=url, headers=headers, auth=(jamf_username, jamf_password))
    if r.status_code != 200:
        html_clean = "\n".join([line.rstrip() for line in r.text.splitlines() if line.strip()])
        raise SystemExit(f"[ERROR] response Text : {html_clean}")
    return r


logging.info(f"[start] Processing serial number {device_serial_number}")
logging.info('[Step 1] Sending refresh cellular plan command')
response = send_command()
http_code = response.status_code
tree = eTree.fromstring(response.text)
command_uuid = tree.findall('./uuid')[0].text
jamf_device_id = tree.findall('./mobile_devices/mobile_device/id')[0].text
logging.info(f"[info] Jamf Command UUID : {command_uuid} for device {jamf_url}/mobileDevices.html?id=${jamf_device_id}")

# We could stop here. But instead of printing the response, some people like
# to do a delay loop where they keep calling that GET on the command UUID
# until apns_result_status has a value, then stop and print that. You have
# to wait a while for the command to go through APNs, though. And if the
# device is offline this will just time out.

if check_command_apns_status:
    logging.info('[Step 2] Checking APNs Status...')
    apns_result_status = ''
    max_time = 60   # in seconds
    loop_delay = 3  # recheck to see if the command went through how often? (in seconds)
    time_used = 0

    while time_used < max_time and not apns_result_status:
        logging.debug(f"Pausing {loop_delay} seconds...")
        time.sleep(loop_delay)
        time_used = time_used + loop_delay
        logging.debug(f"[check] APNs Status Query ({time_used} seconds)")
        response = process_response()
        http_code = response.status_code
        response_json = response.json()
        apns_result_status = response_json['mobile_device_command']['general']['apns_result_status']

    logging.info(f"APNS Result Status : {apns_result_status}")

    """A successful request for command status will look like this.
    (Once the command goes through you'll get apns_result_status = "Acknowledged")

    <mobile_device_command>
      <general>
        <command>RefreshCellularPlans</command>
        <uuid>858cabb7-3267-45fa-bb9a-277a29bf62be</uuid>
        <profile_id>-1</profile_id>
        <e_sim_server_url>https://t-mobile.gdsb.net</e_sim_server_url>
        <date_sent>2021-11-18 06:10:21</date_sent>
        <date_sent_epoch>1637215821488</date_sent_epoch>
        <date_sent_utc>2021-11-18T06:10:21.488+0000</date_sent_utc>
        <apns_result_status/>
        <profile_udid/>
      </general>
      <mobile_devices>
        <size>1</size>
        <mobile_device>
          <id>12</id>
          <management_id>549c8e89-d215-4a2e-806b-f054eb9c8430</management_id>
          <udid>00008020-001251EE2193802E</udid>
          <serial_number>DMPD11NXLMV7</serial_number>
          <phone_number>19526524218</phone_number>
          <wifi_mac_address>34:A8:EB:03:89:08</wifi_mac_address>
        </mobile_device>
      </mobile_devices>
    </mobile_device_command>
"""

Advertisement

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: