Skip to content

Enable users to request MDM Commands in Jamf Self-Service, safely and without exposing Jamf API credentials.

License

Notifications You must be signed in to change notification settings

chrisyeo/command-on-demand

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

30 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

command-on-demand

Enable Self-Service MDM command invocation, without exposing Jamf API credentials in scripts.

How does that work then?

command-on-demand is a web service which sits between your scripts and the Jamf API.

The service exposes a small API surface which your scripts call, authorised with a separate bearer token.

Various checks are carried out to determine how requests are sent to Jamf (or if they should be sent at all).

Warning

☠️ THIS TOOL CAN CAUSE IMMEDIATE AND IRREVERSIBLE DATA LOSS ☠️

  • Make sure you fully understand how it works before venturing further
  • Any example scripts, extension attributes and configurations are purely for convenience and to help you understand and get started with the tool
  • Always use test devices when first trying this out and when testing your deployment
  • Use a test/sandbox Jamf instance where possible
  • See disclaimers below and in LICENSE file

Use cases

This tool was built with a few specific use cases in mind

  • Self-Service Erase All Content & Settings (EraseDevice command)
  • Self-Service software updates (e.g. an InstallASAP command with forced reboot)
  • Any other commands which come in the future and make sense to make available via Self-Service

Without giving users admin or putting Jamf API creds in scripts, this isn't really possible... until now!

Self-Service Erase All Content & Settings

Since macOS Monterey, Macs with a T2 chip or Apple Silicon CPU behave differently when getting an EraseDevice command from Jamf - they quickly return to factory state! 🎉

There are a few reasons to offer a Self-Service, admin-less nuke & pave:

  • Your org frequently loans out devices which need to be cleared for the next user, you don't give field technicians Jamf console access but want to give them (or users) a way to reset the device between loans.
  • You want to wipe your devices as part of an MDM migration 💀

Of course, this tool doesn't handle the UX that you'll likely want to present to your users, but there will be some example scripts added to this repo soon.

Self-Service Software Updates

The softwareupdate command and enterprise macOS updates in general have been inconsistent or just downright broken for a number of years. A number of really great tools have grown up around this problem in an effort to allow admins to empower users to update their machines.

There's often a technical debt associated with using these tools and Apple obviously wants us to use the MDM framework instead (whether it works or not 🙃).

You can use this tool along with a Self Service policy and simple script to allow users to update their machines to the latest available software version.

The tool currently only supports sending the most expeditious version of the command; immediately download and install the latest available OS version, force reboot, include major versions, ask no questions, etc. However, the plan is to add functionality allowing for setting the various options that the Jamf API allows for. For example, only doing minor updates, or targeting a specific version.

Wait... This still requires putting a credential in my script!

Well, yes, but having the bearer token is only the first control, and it certainly beats using Jamf credentials.

Note that clients can only cause a command to be sent to themselves and only if they can prove established trust with the Jamf server, and that they can modify a chosen part of their Jamf device record:

  1. The client requests a code, this is randomly generated by the server and is only valid for two minutes
  2. The client must then cause that code to be set in a designated extension attribute in Jamf via jamf recon
  3. Once the recon process finishes, the client sends a second request for the command itself to be invoked, causing the service to check the value in Jamf
  4. The service then looks up the computer in Jamf and reads the value in the designated extension attribute
  5. If the string in Jamf matches the one that was generated (and it's still valid), the service sends the requested command to that device via the Jamf API

By successfully modifying an extension attribute value using the jamf binary, we can be sure that the device has a valid device certificate which Jamf trusts. Since devices can only update their own device record this way, and taking into account the short lifespan of the code, we can be confident that the requesting client is the one that should receive the command.

This service absolutely should be run behind a load balancer or reverse proxy which handles TLS termination. This also presents the opportunity to use mTLS with client certificates as an extra protection if you need it. Most cloud platforms offer this capability.

Can I use this? (requirements)

  • A functioning Jamf instance with:
    • A working APNS configuration
    • Sufficient access to create a Jamf admin account with the required permissions
    • Sufficient access to create/update the required extension attribute
    • Sufficient access to create/update policies and scripts
  • A way of hosting this service on the public internet
  • Some devices to test with which are enrolled in your Jamf instance
    • DO NOT use your daily driver or machines which contain important data which isn't backed up
  • A working Go installation (1.20 or later)
  • A full understanding of this service, what it does, and how to build & deploy it safely
  • You know how to write (at least) basic bash/zsh or some other scripting language for the Jamf Self-Service policy scripts:
    • To perform the client-side calls to the API
    • (Optional/Recommended) To drive any UI you might want as a front-end to your process
  • (Optional/Recommended) Working ADE/DEP setup if you care about devices being able to re-enroll successfully after an erasure

Should I use this?

Only you can answer that. However, this project is still in its early stages and may not yet suit your environment.

Previously, I've used tools like Okta Workflows or Pipedream to achieve the same thing (and you can too!). But sharing solutions for those platforms isn't always easy and not everyone can/wants to use them... also I wanted to learn Go and give something back to the MacAdmin & Jamf community, so that's why this exists!

There has been limited testing done so far; only on Apple Silicon Macs without Activation Lock set. I'll try to extend the testing to Intel and other device states (Activation/RecoveryOS/Firmware locked, etc).

⚠️ Disclaimer: I'm not an expert (Go) programmer; you may find bugs or see things you don't like (yes, I still need to write tests). I cannot be held responsible for anything (bad) that happens if you use (or don't use) the code in any of the repos on my GitHub. Use at your own risk, see LICENSE, etc. ⚠️

If you want to contribute or point out how things can be improved, please do!

Things I want to implement/improve:

  • Allow passthrough of configurable params to Jamf API for the Software Update command
  • Unit Tests
  • Testing on more hardware and software configurations
  • Logging, Errors and project structure can probably still be done better
  • Continued improvement to overall code quality
  • Outbound webhooks for certain events (e.g. when commands are sent)
  • Any other (good/reasonable) ideas that surface

I'm still here, how do I use it?

I'll write a full Wiki eventually, but here are the cliff notes...

Configure Jamf

  • Create an admin user with the following permissions and set a strong password
    • Jamf Pro Server Objects > Computers > Create & Read
    • Depending on which commands you wish to invoke, at least one of:
      • Jamf Pro Server Actions > Send Computer Remote Wipe Command
      • Jamf Pro Server Actions > Send Computer Remote Command to Download and Install macOS Update
  • Create a Computer Extension attribute:
    • Give it a sensible name (you'll need this later). E.g. cmdod-code
    • Data Type: String
    • Input Type: Script
    • See the examples below for the script content, but you're free to do what you want here. The only requirement is that the extension attribute reads the received code accurately - beware of whitespace.

Extension Attribute Example

This example extension attribute reads out the contents of the file containing the code, deletes it, and then echoes it out for consumption by Jamf. Basic checks are done to ensure that the value is of the expected length and that the file is a regular file. Your client side script must ensure the code is properly written to the same path (do not include a newline!).

#!/bin/zsh

# Command-On-Demand Code Proof Extension Attribute

CODE_TEMP_PATH="/tmp/.cmdod.code"
# CMDOD Server returns a base64-encoded, 32-byte value. With padding, results in 44 characters.
EXPECTED_CODE_LENGTH=44

# Should be a regular file, no symlinks
if [[ -f "$CODE_TEMP_PATH" ]] && [[ ! -L "$CODE_TEMP_PATH" ]]; then
    DATA=$(cat "$CODE_TEMP_PATH" | head -n 1)
    rm -f "$CODE_TEMP_PATH"

    # Ensure that junk values aren't submitted
    if [[ ${#DATA} -eq $EXPECTED_CODE_LENGTH ]]; then
        CODE_VALUE="$DATA"
    fi
fi

echo "<result>$CODE_VALUE</result>"

Self-Service Script Example/Snippet

This example shows the essential steps to perform the code check and erase request. You'd lkely include some GUI with guardrails/confirmations around this functionality.

#!/bin/zsh

# Client script being run from Self Service

# ... snip ...

UDID=$(ioreg -d2 -c IOPlatformExpertDevice | awk -F\" '/IOPlatformUUID/{print $(NF-1)}')
CODE_TEMP_PATH=/tmp/.cmdod.code
CODE_VALUE=$(curl "https://cmdod.example.com/api/v1/code/$UDID" -H "Authorization: Bearer xxx")

# handle curl errors, continuing if ok...

# write code to disk, excluding newline (-n)
echo -n "$CODE_VALUE" > "$CODE_TEMP_PATH"

# Code will be submitted to Jamf...
jamf recon

# Check code proof and erase device
curl -X "POST" "https://cmdod.example.com/api/v1/erase/$UDID" -H "Authorization: Bearer xxx"

# ... snip ....

Building and running the binary (if not using Docker)

  • cd into the cloned repo directory
  • run go build -o command-on-demand cmd/main.go
  • run the app with ./command-on-demand
    • You'll need to set environment variables; you will see errors until these are all present and correct

Run locally for quick testing with Docker

Note: The included Dockerfile is for reference only, tweak it to your preferences, or maybe don't use it at all.

  • Clone this repo and cd into it
  • Create a .env file and set required variable values (see below)
    • Hint: Using openssl rand -hex 32 is useful for generating a bearer token value
  • Run docker build --no-cache -t command-on-demand .
  • Run docker run --env-file .env --rm -p 8080:8080 command-on-demand
    • Don't forget to update the port mapping if you changed the listen port in .env

Run on DigitalOcean (App Platform)

  • Be signed in to a DigitalOcean account with credits available or a payment method set up
    • You can sign up and get $200 of credit for 60 days
  • Click the blue button!
  • Use the Edit Plan button to choose a plan that suits you - this should run fine on the smallest size on the Basic plan ($5 pcm)
    • The smallest instance type is set in the template, but doesn't seem to get applied at the moment 🤨
  • Click Next and then Edit on the Environment Variables for the command-on-demand-api component
  • Enter appropriate values for all require variables
    • Add and set any optional vars if you need them (see .env file example below for a full list of possible vars)
    • For any vars containing sensitive details (credentials/tokens), make sure you click the Encrypt checkbox
  • Save and click Next, picking your preferred region, etc
  • Click Create Resources and wait for the build/deploy phases to finish
  • Congratulations, you've deployed the service. The public URL (ending ondigitalocean.app) will be displayed near the top of the page.

Deploy to DO

Note: The DigitalOcean template uses the Dockerfile included at the root of this repo

Example .env file

# app ignores variables not prefixed with CMDOD_

# Required Variables

# Hostname with domain of your Jamf instance, no need to add the https://
CMDOD_JAMF_FQDN=yourorg.jamfcloud.com

# Credentials for a Jamf admin user with appropriate permissions
CMDOD_JAMF_API_PASSWORD=password
CMDOD_JAMF_API_USER=username

# The name of the Jamf Extension Attribute which will contain the device submitted secret
CMDOD_CODE_PROOF_EA_NAME=example-ea-name

# The bearer token used by clients to make requests to the cmdod service
CMDOD_SERVER_BEARER_TOKEN=veryLongTokenValue

# Optional Variables
# uncomment to change defaults
#CMDOD_SERVER_LISTEN_INTERFACE=0.0.0.0
#CMDOD_SERVER_LISTEN_PORT=8080
#CMDOD_LOG_LEVEL=info

Run in production

If or how you do that is up to you.

Whatever you do, put it behind a balancer/reverse proxy which does TLS termination - the service currently uses only HTTP for its endpoints. (Calls to Jamf are HTTPS)

Are there fuller client side scripts/examples?

These will appear on the wiki in the near future

API

Endpoints

GET /api/v1/code/{udid}

Returns a randomly generated, short-lived (2 mins) code/token. This code is associated with the {udid} given in the path

To help avoid having to parse/unmarshal a JSON object in shell scripts, the code is returned in the body on a single line unless Accept: application/json is included in the request headers:

{
  "code": "thecode"
}

Anything other than a 200 response should be interpreted as an error.

Repeated calls to this endpoint will always yield a new code for the given UDID, replacing the last (if present).

Note: Expired codes are pruned periodically, no cleanup is necessary on your part.

POST /api/v1/erase/{udid}

Requests that an EraseDevice command be sent to the {udid} given in the path.

The command will only be sent if the value of the designated extension attribute in {udid}'s computer record matches the one on record and hasn't expired or been pruned.

Anything other than a 201 response should be interpreted as an error.

Note: The last code for {udid} is "consumed" (expired & subject to pruning) when this endpoint is called, regardless of outcome. Therefore, a new code must be requested and pushed to Jamf before calling this endpoint again.

POST /api/v1/swupd/{udid}

Requests that a software update command be sent to the {udid} given in the path.

Currently, this command causes the device to download and install the latest OS version available to it and forces a reboot once the preparation phase is done.

The following parameters are used in the call to the Jamf API (api/v1/macos-managed-software-updates/send-updates):

{
  "skipVersionVerification": true,
  "applyMajorUpdate": true,
  "forceRestart": true,
  "priority": "HIGH",
  "maxDeferrals": 0,
  "updateAction": "DOWNLOAD_AND_INSTALL"
}

The command will only be sent if the value of the designated extension attribute in {udid}'s computer record matches the one on record and hasn't expired or been pruned.

Anything other than a 201 response should be interpreted as an error.

Note: The last code for {udid} is "consumed" (expired & subject to pruning) when this endpoint is called, regardless of outcome. Therefore, a new code must be requested and pushed to Jamf before calling this endpoint again.

Responses

Error

An error response body will contain information about the error and its origin.

Example:

{
    "status": 404,
    "message": "computer not found",
    "isError": true,
    "errorOrigin": "jamf"
}

Error Origins

  • jamf an error returned by an API call to Jamf, the status code is the one returned by Jamf
  • service an error that occurred internally when processing a request
  • request an error resulting from an incorrect or unexpected request to the service (i.e. from your client code 😜)
  • unknown an error which cannot be categorised as any of the above. These shouldn't ever really be seen in typical use.

Success

Unless otherwise stated, responses for successful operations will usually conform to the same schema as in the example above, just with isError set to false and without the errorOrigin key.

About

Enable users to request MDM Commands in Jamf Self-Service, safely and without exposing Jamf API credentials.

Topics

Resources

License

Stars

Watchers

Forks