Our desktop support & G Suite admin folks needed a simple, fast command-line tool to query basic info about our company’s mobile devices (which are all managed using G Suite’s built-in MDM).
So I wrote one.
Since this tool needs to be run via command-line, it can’t use any interactive or browser-based authentication, so we need to use a service account for authentication.
Pre-requisites (GCP & G Suite):
- Create a service account in your G Suite domain (Google Cloud Console –> IAM & Admin –> Service Accounts)
- Be sure that you have checked the “Enable G Suite Domain-wide Delegation” box as this will give your new service account the appropriate permissions to query the G Suite Admin API

- Download the service account JSON credentials file. Open it up and find the
client_id
. Make a note of it! - Now we need to authorize the
client_id
of your service account for the necessary API scopes. In the Admin Console for your G Suite domain (Admin Console –> Security –> Advanced Settings –> Authentication –> Manage API Client Access), add yourclient_id
in the “Client Name” box, and addhttps://www.googleapis.com/auth/admin.directory.device.mobile.readonly
in the “One or more API scopes” box

- Click the “Authorize” button when you’re finished
Pre-requisites (Go)
You’ll need to “go get” a few packages:
go get -u golang.org/x/oauth2/google
go get -u google.golang.org/api/admin/directory/v1
go get -u github.com/dustin/go-humanize
Pre-requisites (Environment Variables)
Because it’s never good to store runtime configuration within code, you’ll notice that the code references several environment variables. Setup them up to suit your preference but something like this will do:
export GSUITE_COMPANYID="A01234567" export SVC_ACCOUNT_CREDS_JSON="/home/rickt/dev/adminsdk/golang/creds.json export GSUITE_ADMINUSER="foo@bar.com"
And finally, the code
Gist URL: https://gist.github.com/rickt/199ca2be87522496e83de77bd5cd7db2
package main | |
// https://rickt.org/2019/01/08/playing-with-g-suite-mdm-mobile-device-data-using-go/ | |
import ( | |
"context" | |
"errors" | |
"flag" | |
"fmt" | |
"github.com/dustin/go-humanize" | |
"golang.org/x/oauth2/google" | |
"google.golang.org/api/admin/directory/v1" | |
"io/ioutil" | |
"log" | |
"os" | |
"strings" | |
"time" | |
) | |
// runtime usage: | |
// Usage of ./mdmtool: | |
// -all | |
// List all MDM mobile devices | |
// -imei string | |
// IMEI of mobile device to search fors | |
// -name string | |
// Name of mobile device owner to search for | |
// -sn string | |
// Serial number of mobile device to search for | |
// -status string | |
// Search for mobile devices with specific status | |
var ( | |
adminuser = os.Getenv("GSUITE_ADMINUSER") | |
companyid = os.Getenv("GSUITE_COMPANYID") | |
credsfile = os.Getenv("SVC_ACCOUNT_CREDS_JSON") | |
devices *admin.MobileDevices | |
row int = 0 | |
scopes = "https://www.googleapis.com/auth/admin.directory.device.mobile.readonly" | |
searchtype = "all" // default search type | |
sortorder = "name" // default sort order | |
) | |
// flags | |
var ( | |
all *bool = flag.Bool("all", false, "List all MDM mobile devices") | |
imei *string = flag.String("imei", "", "IMEI of mobile device to search for") | |
name *string = flag.String("name", "", "Name of mobile device owner to search for") | |
sn *string = flag.String("sn", "", "Serial number of mobile device to search for") | |
status *string = flag.String("status", "", "Search for mobile devices with specific status") | |
) | |
// helper func to check errors | |
func checkError(err error) { | |
if err != nil { | |
log.Fatal(err) | |
} | |
} | |
// check the command line arguments/flags | |
func checkFlags() (string, error) { | |
// parse the flags | |
flag.Parse() | |
// show all devices? | |
if *all == true { | |
// -all shows ALL devices so doesn't work with any other option | |
if *name != "" || *imei != "" || *sn != "" || *status != "" { | |
return "", errors.New("Error: -all cannot be used with any other option") | |
} | |
return "all", nil | |
} | |
// name search | |
if *name != "" { | |
// don't use -name and any other search option | |
if *imei != "" || *sn != "" || *status != "" { | |
return "", errors.New("Error: cannot use -name and any other search options") | |
} | |
return "name", nil | |
} | |
// IMEI search | |
if *imei != "" { | |
// don't use -imei and any other search option | |
if *name != "" || *sn != "" || *status != "" { | |
return "", errors.New("Error: cannot use -imei and any other search options") | |
} | |
return "imei", nil | |
} | |
// Serial number search | |
if *sn != "" { | |
// don't use -sn and any other search option | |
if *name != "" || *imei != "" || *status != "" { | |
return "", errors.New("Error: cannot use -sn and any other search options") | |
} | |
return "sn", nil | |
} | |
// Status search | |
if *status != "" { | |
// don't use -status and any other search option | |
if *name != "" || *imei != "" || *sn != "" { | |
return "", errors.New("Error: cannot use -status and any other search options") | |
} | |
return "status", nil | |
} | |
// invalid search | |
if *all == false && *name == "" && *imei == "" && *sn == "" && *status == "" { | |
flag.PrintDefaults() | |
return "", errors.New("Error: no search options specified") | |
} | |
return "", nil | |
} | |
// helper function to do a case-insensitive search | |
func ciContains(a, b string) bool { | |
return strings.Contains(strings.ToUpper(a), strings.ToUpper(b)) | |
} | |
func main() { | |
// check the flags to determine type of search | |
searchtype, err := checkFlags() | |
checkError(err) | |
// read in the service account's JSON credentials file | |
creds, err := ioutil.ReadFile(credsfile) | |
checkError(err) | |
// create JWT config from the service account's JSON credentials file | |
jwtcfg, err := google.JWTConfigFromJSON(creds, scopes) | |
checkError(err) | |
// specify which admin user the API calls should "run as" | |
jwtcfg.Subject = adminuser | |
// make the client using our JWT config | |
gc, err := admin.New(jwtcfg.Client(context.Background())) | |
checkError(err) | |
// get the data | |
devices, err = gc.Mobiledevices.List(companyid).OrderBy(sortorder).Do() | |
checkError(err) | |
// iterate through the slice of devices | |
for _, device := range devices.Mobiledevices { | |
// what type of search are we doing? | |
switch searchtype { | |
// show all mobile devices | |
case "all": | |
row++ | |
printDeviceData(device) | |
// name search: iterate through the slice of names associated with the device | |
case "name": | |
for _, username := range device.Name { | |
// look for the specific user | |
if ciContains(username, *name) { | |
row++ | |
printDeviceData(device) | |
} | |
} | |
// IMEI search: look for a specific IMEI | |
case "imei": | |
// remove all spaces from IMEI then search for specific IMEI | |
// IMEI can be misreported via MDM with spaces, so remove them | |
if strings.Replace(device.Imei, " ", "", –1) == strings.Replace(*imei, " ", "", –1) { | |
row++ | |
printDeviceData(device) | |
break | |
} | |
// serial number search: look for a specific serial number | |
// SN can be misreported via MDM with spaces, so remove them | |
case "sn": | |
if strings.Replace(device.SerialNumber, " ", "", –1) == strings.Replace(*sn, " ", "", –1) { | |
row++ | |
printDeviceData(device) | |
break | |
} | |
// Status search | |
case "status": | |
if ciContains(device.Status, *status) { | |
row++ | |
printDeviceData(device) | |
} | |
} | |
} | |
// if 0 rows returned, exit | |
if row == 0 { | |
log.Fatal("No mobile devices match specified search criteria") | |
} else { | |
// print the final/closing line of dashes | |
printLine() | |
} | |
fmt.Printf("%d row(s) of mobile device data returned.\n", row) | |
} | |
// func to print out device data | |
func printDeviceData(device *admin.MobileDevice) { | |
// print header only on first row of data | |
if row == 1 { | |
printHeader() | |
} | |
// convert last sync string to time.Time so we can humanize the last sync timestamp | |
t, err := time.Parse(time.RFC3339, device.LastSync) | |
checkError(err) | |
fmt.Printf("%-16.16s | %-14.14s | %-16.16s | %-18.18s | %-13.13s | %-18.18s | %-20.20s\n", device.Model, device.Os, strings.Replace(device.SerialNumber, " ", "", –1), strings.Replace(device.Imei, " ", "", –1), device.Status, humanize.Time(t), device.Name[0]) | |
return | |
} | |
// func to print a line | |
func printLine() { | |
// print a line | |
fmt.Printf("—————–+—————-+——————+——————–+—————+——————–+—————\n") | |
} | |
// func to print the header | |
func printHeader() { | |
// print the first line of dashes | |
printLine() | |
// print header line | |
fmt.Printf("Model | OS & Version | Serial # | IMEI | Status | Last Sync | Owner\n") | |
// print a line of dashes under the header line | |
printLine() | |
} | |
// EOF |