Install Microsoft O365 Apps direct from CDN via script in Jamf (or munki for that matter)

Hate trying to keep up to date with Office updates? Couldn’t be bothered importing gigabytes of data into your local software repo? Annoyed that deploying Office via VPP automatically takes ages and is seemingly random? Download direct from Microsoft’s CDN – and do it from a re-usable script as part of a jamf policy or munki no-pkg – and have it update DEPNotify status with a percentage count as it downloads and also as it’s installing. 👍

This is my script and I wrote it for my environment – it comes without warranty but feel free to use it, copy it, modify it, burn it or sacrifice it to the gods. There are bits of this I found on the internet and I can’t remember where I got it from (the pulling % downloaded from curl output) – I’ll update here for proper attribution when I find where I got that….on stackoverflow somewhere.

I use this in my DEP process as I found installing VPP from the app store was hit and miss. It could be re-purposed for self service or some other use. It presumes DEPNotify is already running (which in my case it is)

If you want to try it out, add this to a policy and pass “Word”, “Outlook”, “Excel” or “PowerPoint” as a parameter and it will (should) find the download link, kick off the install and clean up after itself.

Not tested as a nopkg in munki (yet) but there shouldn’t be a reason it couldn’t work with some minor modifications.


Jamf smart group with lots of serial numbers and a bit of regex

Had a one off requirement recently to add around 100 unknown-to-jamf (not yet enrolled, not in DEP) devices to a smart group. I wanted the devices to be in the group as soon as possible so I didn’t want to rely on an EA, so since I knew all the serial numbers I thought I would add these in explicitly to create the group.

The Jamf UI doesn’t allow for things like “if item in list”, at least not for serial numbers (that I could find). Faced with the prospect of adding in 100+ individual “where serial number is C0ABCDE12345” I needed something to scratch my “do as little work as possible and get the same result” itch.

Enter regex

The following regex will match a given serial number against a list (in this case, 3)


To populate 100+ serial numbers though is a challenge. The regex field has a 255 character limit, although the UI won’t tell you that if you paste in a fat chunk of text. Turns out with the standard 12 character Apple serial number you can fit 19 serials including regex into 254 characters.

So, it was just a matter of putting all my serials into a text editor, seperate into 19 per line, add regex and paste the result in.

The result is only 6 OR’d items in the smart group for “Serial Number matches regex” rather than 100+ individual “OR serial number equals OR Serial Number equals etc…”

And as a bonus – it actually worked 😉

sudo -u vs launchctl asuser

Update: Scripting OS X has an excellent post on this topic over on his blog – well worth the read

The Other Day I was troubleshooting an issue I was having with a jamf policy that was designed to send a notification to a group of computers. I scoped it to a group of computers, set it to run once a month and waited to see how the deployment progressed.

Nothing happened and the number complete in the policy status didn’t increase even after an hour or more (other than my two test machines)

Troubleshooting the issue I discovered there was a problem with a notification service I had deployed (a custom fork of the excellent yo ). The policy was running but the notifications were not being sent. There was an error in the logs to the effect that there was no display and so the entire jamf policy run was being held up by hours until the process eventually timed out or the machine was rebooted – nothing was running. This was strange as I had set up the script to send the notifications as the current user using sudo -u username ... and everything worked well in testing.

It all worked fine if called from a self service policy or if triggered with a sudo jamf policy -trigger ... on the command line and so did not present as an issue during testing. What I had failed to test is when the policy run is launched every 15 minutes via launchd as directed in /Library/LaunchDaemons/com.jamfsoftware.task.1.plist

In this context there was no display for the process to access, and so running a command via sudo -u would also not have a display, hence the notification service hanging.

launchctl asuser, as per the man page, “…executes the given command in as similar an execution context as possible to that of the target user’s bootstrap”. It requires UID instead of username as the parameter. If you get the current logged in user with either stat -f%Su /dev/console or with the $3 parameter that jamf passes into all scripts (or <insert favourite method here>), you can then get UID with a simpleid -u "$loggedInUser".

For running a command you could do:

launchctl asuser <uid> command

launchctl will then execute the command in the users context, including access to the display for programs that have a UI component, even when launched via a system launch daemon. 👍

For loading and unloading launch agents you would so something like:

launchctl asuser <uid> launchctl load /Library/LaunchAgents/com.some.agent.plist

sudo -u has it’s place but if you’re wanting to ensure to are running in the users context, launchctl asuser seems to be the better fit.

(not mentioned here – all the code and checks to make sure there’s a user to send a notification to in the first place – usually a good thing to do and be aware of)

no client workstations were harmed in the production of this post

@bartreardon on macadmins slack, twitter and instagram

DEP Nag, Jamf Pro and Unexpected Status (403)

In my org we have been rolling out Jamf Pro MDM to all new Mac devices. We are now extending that to existing devices using the the super cool tool UMAD which I am deploying to existing devices all over the country via our existing Munki setup. This is a nice fancy way to run sudo profiles renew -type enrollment and provide a pretty dialog that gives context to the notification the user just got, allowing them ample opportunity to opt in to enrolment at a suitable time, while not letting them forget that they need to enrol (thank you UAMDM).

On a number of devices though, the end users, after accepting the DEP enrolment notification, received the following error:

"Device Enrolment" installation failed. The MDM server for your organisation returned an unexpected status (403).

Fortunately I staged my deployment so the initial batch of 60 odd devices were the only ones targeted for the enrolment notification. Of those devices though, 12 of them had this error – as a failure rate percentage that’s way too high.

This error is occurring post push notification but before device enrolment and only on some devices. Initial thoughts were towards some existing configuration getting in the way or an incompatibility with hardware/OS but many were running macOS 10.14.4 and on very recent hardware, although every device was at least a year old or older. The significance of this, I didn’t realise until later.

A quick google search and I find out that if you delete the /Library/Keychains/apsd.keychain and reboot, everything continues to work. I wasn’t super keen on requiring 600 odd devices to have to reboot (I like to avoid reboots if I can at all help it) so decided to look deeper into what was causing the problem.

UPDATE: Here be dragons. Deleting this keychain is not recommended by Apple. Doing so may cause an issue with iCloud services. The only supported way to go through DEP re-enrollment is to wipe the device and go through an initial setup where DEP is forced. (thanks Graham Pugh for forwarding on that info).

Taking a look at the Jamf server logs and I start to see entries like this that correspond with when I initially deployed UMAD :

...[dEnrollmentIssuerVerifier] - Unable to validate issuer Certification path could not be validated.
...Processing device rejection: FAILED_SIGNATURE_CHECK

Inspecting the apsd.keychain on the machines with the issue and I discover that the certificate within has expired. Ah – that’s why it was only happening on devices that were a year or more older. The certificate is initialised when the OS is first installed/configured and expires after 12 months. The expiry date easily checked with the following:

/usr/bin/security find-certificate -a -p -Z /Library/Keychains/apsd.keychain | /usr/bin/openssl x509 -noout -enddate| cut -f2 -d=

Initially I thought this was a problem with macOS not renewing the certificate or with our network not allowing comms with over whatever port. Turn out it’s neither of those things. The (deprecated apparently) document on OTA Profile Delivery and Configuration has this little nugget inside (and thank you to @jessepeterson over on the macadmins slack for bringing this to my attention 🍻)

WARNING:  When device certificates signed “Apple iPhone Device CA” are evaluated their validity dates should be ignored.

So, the MDM is seemingly doing the right thing by checking the validity of certificates but actually they should just ignore the expiry date and just validate that the signing certificate is issued from “Apple iPhone Device CA”.

For now, the only workaround is to delete (or rename) /Library/Keychains/apsd.keychain, reboot and try again or manually enrol. So much for ease of deployment to hundreds of remote devices 🤷‍♂️. As it turns out, most of my 600+ devices are are year old or older so either I delete apsd.keychain and force a reboot on all devices or re-do my enrolment workflow.

From what I can tell, this is also a Jamf Pro only thing (and I have raised a support case to look into it further). Other MDM’s may behave differently (e.g. micromdm will happily accept your profile signed with an expired apsd certificate, as per the mdm spec)

It was asked if apsd.keychain was protected by SIP. It isn’t (which is a good thing for us so it turns out). You can always check for files and folders protected by SIP by looking in the file /System/Library/Sandbox/rootless.conf. Apple’s documentation on SIP here also specifically mentions /Library as an area applications can continue to write to although there are a number of folders protected by SIP in there. Keychains isn’t one of them.


Munki Hacking for Profit

In a previous post I mentioned we can insert CSS into our footer page to change the way Managed Software Centre looks. Just as easily, we can add javascript to add some conditional logic.

in my environment we allow clients to purchase software individually. In many cases we will have a number of licenses and we want to make that available for clients to use, however we want to track the usage somehow. It would be nice to be able to allow this functionally within the MSC app and have the [INSTALL] buttons say and do something else if a client is not yet entitled to install that software.

The steps we need to take are:

  • Grab a list of software and a pricelist
  • grab a list of software the current user is entitled to
  • provide a web form that allows a user to register for a particular piece of software

This gets a a little tricky as it needs some external data and a way to update that data. A basic database with tables for software and prices and a table for user registrations. (this is the part where the reader needs to supply their own infrastructure). For the pricelist and software entitlements, I like to use a bit of CGI that reads the relevant tables for the data and spits out some JSON that we can read and use – essentially, where the data resides is up to you, as long as you can get it in the right format, even a static flat file will do.

The output though looks a little something like this:

Pricelist JSON

        "appName": "Adium",
        "price": "1.99"
        "appName": "MacPorts",
        "price": "0.99"
        "appName": "GoToMeeting",
        "price": "299.00"

appName matches the package name in Munki – this gets important in a bit

So to start with, in our footer we want to create a javascriopt block to put our code in and set up some global variables:

<script type="text/javascript">

var pricesArray = [];
var purchasesArray = [];
var userName = "";


We want a way to get a generic JSON file from wherever you’re getting it from (you will want to check how your server is set up – The html is rendered from a local file:// location and there are some rules around javascript requesting pages from other sources)

function getJson(url) {
	var request = new XMLHttpRequest();
	var appArray = [];'GET', url, false);
	if (request.status === 200) {
		appArray = JSON.parse(request.responseText);
	return appArray;

Next we want to populate our price list array

function priceListInit() {
	var url = "http://localhost/munki_repo/pricelist.json"; //custom - call some generator or static file, return json
	return getJson(url);

pricesArray = priceListInit();  

The list of apps that a user has access to is also required – the JSON for that looks like this

Purchases JSON

        "appName": "Foo"

Again, this is where you need to set up some infrastructure to dynamically collect this info. 

We can get the current user from the path created by the html MSC generates. It’s kept in “/Users/username/Library/Caches/com.googlecode.munki.ManagedSoftwareCenter/html” 

A quick and dirty way of extracting this info using JS goes something like this:

function MSCUser() {
    var pathArray = window.location.pathname.split( '/' )
    var userName = pathArray[2];
    return userName;

userName = MSCUser();

here we are reading the current files path, splitting on “/” and extracting the username

now we can get the purchases for the current user

function userPurchasesInit(currentUser) {
    var url = "http://localhost/munki_repo/purchases.aspx?username="+currentUser;
    return getJson(url);

purchasesArray =  userPurchasesInit(userName);

we also need a function to re-direct users if they aren’t licensed for a particular item – rather than perform an install we want to have them action a purchase form or the like – MSC can render regular web pages (to a degree, but it’s pretty good) so a simple web form will suffice – the logic you use is up to you but you want to take the username, the app they want and some way of recording a payment info (I use an internal payment system so I get them to enter their details and from there the item is “unlocked”)

function buyApp(appName) {
    // opens the app purchase form - integrated auth
    displayPage(true, ''+appName);

for the software request form, again, I use a simple page that collects the right info and saves it into the previously mentioned databases.

Next, a way to check the purchases status of a particular item – this is called in the main loop from the main loop as we’re going through the list of purchasable apps

function checkPurchasedStatus(appName, currentUser) {
    // verify if the application has been purchased
    // this portion will need to be deployment specific
    var result = false;
    for(var i = 0; i < purchasesArray.length; i++) {
        if (appName == purchasesArray[i].appName+ purchasesArray[i].appVersion || appName == purchasesArray[i].appName) {
            result = true;
    return result;

Finally, the main loop – here’s how this one works

we get all the elements that have a class of “msc-button-inner not-installed”

for each element, we grab the app name then check if it exists in the prices array and is not already purchased, we modify the element inner html to reflect the price and what action to take.

function processPrices(pricesArray) {
    var installButtons = document.getElementsByClassName('msc-button-inner not-installed');
    for (var i = 0; i < installButtons.length; i++) {
        var appName = installButtons[i].id.replace('_action_button_text','');
        for(var p = 0; p < pricesArray.length; p++) {
            if (pricesArray[p].appName.includes(appName) && checkPurchasedStatus(appName) != true) {
                var appButton = document.getElementById(installButtons[i].id);
                if (appButton.innerHTML.indexOf("Install") > -1 && appButton.innerHTML.indexOf("Installed") <= 0) {
                    if (pricesArray[p].type.indexOf("Perpetual") > -1) {
                        licType = "Purchase"
                    } else {
                        licType = pricesArray[p].type
                    appButton.innerHTML = licType+' : $'+pricesArray[p].price;
                    appButton.parentNode.onclick = function(){ buyApp(this.children[0].id.replace('_action_button_text','')); };
                    appButton.className = appButton.className + ' buy';

The last thing we want o do, is initiate processPrices() when the page loads. We also want to call it every couple of minutes to refresh the page

setInterval(function () {priceListInit()}, 120000); // refresh the buttons every 2 minutes

You can see the code, in context, on my github

Running python scripts as the user in JAMF

This is a quick one and one I use from time to time (file under “documenting little things I do but haven’t written down” – may already be common knowledge even)

Every so often I want to run a bunch of stuff as the user. Jamf scripts have the handy feature where argument 3 to the script is the username. If it’s a simple one line thing then usually “sudo -u $3 …” in a bash script will do the trick but sometimes I want a whole script to be run as the user. Rather than shell out the entire thing I put this at the top of the script

import pwd, os

uname = sys.argv[3]
uid = pwd.getpwnam(uname)[2]



And now the rest of the script runs in the context of the user. Obviously this makes no sense when running in other contexts but for self service items it’s handy.

Munki hacking for Fun

At X World 2017 in Sydney I gave a talk on “Munki Hacking for Fun and Profit”. You can watch that on youtube if you’d like. I’ve been meaning to write down what I did and never really got around to doing it so as a first post, I’d like to rectify that.

What we’re doing is not really hacking, but taking advantage of the fact that Managed Software Centre (MSC) renders content in HTML and gives admins the ability to provide customisations via the client customisation mechanic.

The three files used in MSC are:

  • showcase_template.html — controls the banner images and any links
  • sidebar_template.html — the right-side sidebar displayed in the main Software view
  • footer_template.html — the page footer

showcase_template.html already has some javascript in it for running the rotation of banner images so from this we know that we can run JS code in MSC rendered pages without too much issue. This will come in handy later. footer_template.html though has the distinction of being included on every view displayed in MSC. Taking advantage of this, any javascript or css code in this file will be included with every view rendered in MSC. We can use this to our advantage to take over how items in MSC are displayed.

For a simple example of what we can do, let’s look at changing the button colours from the plain grey to something more colourful. The main CSS file for MSC is called base.css and you can look at that in all its detail on github. We don’t need to replicate all of this file, just override the bits that we want to change. I’d encourage digging around just to see how it all comes together though and get an idea of how it’s all structured.

The following piece of CSS will override only the background value for the small MSC buttons (as shown in category views) to make them green for uninstalled items, with a highlight to a lighter green on hover. Grey for installed items with a red highlight on hover. We also include a small transition time on hover which makes for a more plesent user experience. Paste this into footer_template.html of your customisations and give it a go:

/* give the button a transition time when changing colours */
div.msc-button-inner {
	transition: background-color 0.2s;

/* colour of items not installed */
div.msc-button-inner.not-installed:not(.large) {
    background: #53a82f;

/* colour of items not installed on hover */
div.msc-button-inner.not-installed:hover {
    background: #7ad85d;

/* colour of items installed */
div.msc-button-inner.installed {
    background: #A8AAAF;

/* colour of items installed on hover */
div.msc-button-inner.installed:hover {
    background: #cc1a2f;

Your INSTALL button for items should now be green:

Hover over it and it should go a lighter shade of green:

The REMOVE button will still look the normal grey:

But hover over it and it should change to red:

A couple of things are going on here. In base.css div.msc-button-inner doesn’t have a value set for transition. We’ve added that in. Likewise, we added in hover for div.msc-button-inner.installed and div.msc-button-inner.not-installed. So not only can we change values that are already present, we can add new ones as well.

In this way we can modify various aspects of how items are displayed. You could if you wanted put fat outlines around everything, change the radius of rounded items and generally go nuts…with great power comes great responsibility…but you want to avoid that if at all possible (check the end of my talk for an extreme example of that). Also you don’t need to replicate the entire CSS for each button – just the parts you want to change. Care must also be taken to account for Dark Mode.

In part 2 “Munki Hacking for Profit” I’ll go through some more advanced changes including some conditional logic to turn MSC into a storefront using some javascript.