Adding a JIRA issue collector to Jamf Self Service for user feedback

If your org is one of the many that uses JIRA internally for tracking workflow or projects then you’ll know about issue collectors. I’ve used issue collectors before in munki and my post on that is still available here https://groups.google.com/d/msg/munki-dev/PwvrYaqKxGc/97w7G-USFwUJ

For Jamf Self Service it needs a little bit of extra work as unlike Managed Software Centre there’s not a lot of interface modification you can do. That said, it’s still fairly simple to set up.

Step 1 – create your issue collector in JIRA – consult your jira docco on how to do that but the simplest is to set “Prominent” trigger style and then pick a template. I went with “Custom” and selected “Description” and “Attach File” custom fields. Add an appropriate trigger text and message.

Step 2 – create a page to add the generated issue collector code to. Here’s a template I created earlier:

<html>
    
<head>
</head>

<body>
</body>

</html>

Save it as feedback.html and place it on a web server somewhere.

Step 3 – We aren’t done yet (believe it or not). We need to add in our issue collector code. Go grab it from the issue collector setting in JIRA and paste it in between <body> and </body> tags. All going well, when you re-load your html file you should have a blank page with a JIRA feedback link at the top like this:

That’s not super ideal though – we want to see the form straight away. A simple way to do this (because JIRA is weird and don’t let you easily just create a blank issue collector page) is to create an onload event and have a snippet of JS click the “Provide Feedback” link for us.

Step 4 – Copy the following into the body of your ever growing html file:

<script>  
    window.addEventListener('load', function() {
    document.getElementById('atlwdg-trigger').click(); 
    })
</script>

Now if you re-load your page it should pop up the issue collector straight away (bonus points if you have SSO enabled and it picks up who you are straight away – otherwise have a play around with creating an anonymous issue collector):

Step 5 – Now jump onto your JSS and create a new bookmark and link it to the URL of the feedback html (icon shamelessly ripped off from the Apple Feedback Assistant)


Boom.

Optional – I also created a background image to display on the page so when someone submits their feedback and the form disappears, they see a happy message 😃

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. 👍

https://github.com/bartreardon/jamfscripts/blob/master/download_o365_apps.sh

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)

^.*(C0ABCDE12345|C0ABCDE67890|C0ABCDE99999).*$

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

Yesterday 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 https://github.com/sheagcraig/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".

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. 👍

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
java.security.cert.CertPathBuilderException: 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 17.0.0.0/8 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)

UPDATE:
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 https://support.apple.com/en-au/HT204899 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.

References:
https://github.com/erikng/umad
https://eclecticlight.co/2017/11/28/more-keychains-than-meets-the-eye/
https://i.blackhat.com/us-18/Thu-August-9/us-18-Endahl-A-Deep-Dive-Into-macOS-MDM-And-How-It-Can-Be-Compromised-wp.pdf
https://www.jamf.com/jamf-nation/discussions/14845/getting-expiry-date-of-certificate
https://www.jamf.com/jamf-nation/discussions/31591/intermittent-client-error-when-prompting-for-enrolment-unexpected-status-403
https://www.jamf.com/jamf-nation/discussions/29413/device-enrollment-installation-failed-the-mdm-server-for-your-organization-returned-an-unexpected-status-403
https://simplemdm.com/2017/11/01/user-approved-mdm-enrollment/


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 = "";

</script>

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 = [];
	
	request.open('GET', url, false);
	request.send(null);
	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, 'http://munki.your.org/Purchase/'+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

priceListInit();
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

#!/usr/bin/python
import pwd, os

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

os.setuid(uid)

...

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.