RMM Check: Unifi Health

At Slingshot, we’ve moved our Managed clients over to Unifi networking systems, and I wanted to use our RMM to directly monitor the Unifi Controller. The API isn’t officially documented, but I did find other resources: the community’s API documentation, CyberDrain’s examples, and the Unifi API browser. Add tons of other research and trial/error, and I finally accomplished a Powershell RMM script that monitors Device connections, resources/ports, and Alerts, and reports on a ton more.

Enough with the intros. Here’s an instance currently alerting in Solarwinds RMM:

…and More Info gives the full report (looks better in console — I wish SWRMM preserved whitespace):

Before I forget, some quick notes:

  • Needs a local account (limited admin/readonly) on controller
  • For UDMPs with firmware 1.6 or greater, use port 443; For older controllers, use port 8443
  • It defaults the Controller IP to the detected Gateway IP (we do a lot of UDMPs).
  • Several of the Device Status codes are documented nowhere, so this script might have the only public record of them (for posterity, I’ve figured out 2=pending adoption, 9=inform error, and 11=isolated).

And finally the Powershell — (self-consciously) still in progress with plenty of debug stubs, BUT with lots of useful production miles already under its belt:

<#	checkHealth-UnifiController.ps1
Purpose: for all sites on a controller, checks device connections, resources, ports, alerts
Author: 	Slingshot Solutions, www.slingfive.com
Params:		$IP (or hostname) of controller (default: detected gateway), [int]$PortNum (default:443), Username, PASSWORD
Notes:
* needs a local account (limited admin/readonly) on controller
* For UDMPs with firmware 1.6 or greater, use port 443;  For older controllers, use port 8443
#>

# PREP SCRIPT:
param(
  $IP = (Get-wmiObject Win32_networkAdapterConfiguration |where-object{$_.IPEnabled -and $_.DefaultIPGateway}).DefaultIPGateway[0],
  $PortNum = 443, # or 8443 depending on controller version
  $Username,
  $Password
)
# tweaks:
trap { $_; exit 1 }	# force RMM to treat unhandled runtime errors as ERROR!
#widen host buffer for better output on old PS versions:
$pswindow=(Get-Host).UI.RawUI
$newsize=$pswindow.BufferSize; if($newsize.width -lt 100){$newsize.width=100}; $pswindow.BufferSize=$newsize
$newsize=$pswindow.WindowSize; if($newsize.width -lt 100){$newsize.width=100;$newsize.height=20}; $pswindow.WindowSize=$newsize
function short([string]$str,[int]$size){if($str.length -gt $size){return ($str.substring(0,$size-3)+"...")}else{ return $str}}
# vars:
$exitcode = 0
$report = ""
$FullRpt = ""
$StopWatch = [system.diagnostics.stopwatch]::startNew()

if(!$Password){
	write-host "PARAMETERS:"
	foreach ( $key in ((Get-Command -Name $MyInvocation.InvocationName).Parameters).Keys ) {
		$val = ((Get-Variable $key -ea SilentlyContinue).Value) -join ", "
		Write-Host "  $($key): $val" 
	}
	write-error "Hey dummy, set your params!"
	exit 1000
}


$Credential = @{  username="$Username"; password="$Password"; remember=$True; strict=$True; } |ConvertTo-Json
if($PortNum -eq 443){ # UnifiOS 1.6 or newer:
	$BaseURI = "https://$($IP):$($PortNum)/proxy/network"
	$LoginURI = "https://$($IP):$($PortNum)/api/auth/login"
}elseif($PortNum -eq 8443){ # EdgeOS 1.5 firmware or older:
  $BaseURI = "https://$($IP):$($PortNum)"
	$LoginURI = "https://$($IP):$($PortNum)/api/login"
}

# CONNECT TO CONTROLLER
[Net.ServicePointManager]::SecurityProtocol = "tls12, tls11, tls"
[Net.ServicePointManager]::ServerCertificateValidationCallback = { $True }
#$LoginURI
Try {
	$login = Invoke-RestMethod -uri $LoginURI -Method POST -Body $Credential -ContentType "application/json" -SessionVariable UniFiSession | out-null
}catch{	#wrong username/password = "The remote server returned an error: (400) Bad Request"
	write-error "* ERROR - Api Connection Error: $($_.Exception.Message)"    
	$exitcode = 1010
}
if($exitcode -eq 0){
	#write-host "CONNECTED`r`n"
}



# GET ALL SITES
if($exitcode -eq 0){
	try {
		$urlSites = "$($BaseURI)/api/stat/sites"
		$sites = Invoke-Restmethod -Uri $urlSites -Method GET -WebSession $UniFiSession
	}catch{
		#$report += "* ERROR - Sites Query Failed: $($_.Exception.Message)`r`n"
		write-error "* ERROR - Sites Query Failed: $($_.Exception.Message)`r`n"
		$exitcode = 1011
	}
}

# LOOP THROUGH SITES
if($exitcode -eq 0){
	# DIRTY HACK: UDMPs lose their auth on the second call (Something ServerCertificateValidationCallback related), so just pound it back every time:
	Invoke-RestMethod -uri $LoginURI -Method POST -Body $Credential -ContentType "application/json" -SessionVariable UniFiSession | out-null

	$rptDevices = ""
	$rptAlarms = ""
	Foreach ($site in $sites.data){  # SITE
		try {
			$urlDevices = "$($BaseURI)/api/s/$($site.name)/stat/device/"
			$devices = Invoke-Restmethod -Uri $urlDevices -Method GET -ContentType "application/json" -Headers @{"Accept"="application/json"} -WebSession $UniFiSession
		}catch{
			write-error "* ERROR - Device Query Failed: $($_.Exception.Message)`r`n"
		}
		$shortsitename = short $site.desc 7

		$FullRpt += "`r`nSITE '$($site.desc)' - $urlDevices :`r`n"

		$DeviceStateNames = @{0="disconnected"; 1="connected"; 2="pending adoption"; 3="3(?)"; 4="upgrading"; 5="provisioning"; 6="heartbeat missed"; 9="inform error"; 11="isolated" }
		#https://ubntwiki.com/products/software/unifi-controller/api:
		$DeviceModelNames = @{"BZ2"="UniFi AP"; "BZ2LR"="UniFi AP-LR"; "U2HSR"="UniFi AP-Outdoor+"; "U2IW"="UniFi AP-In Wall"; "U2L48"="UniFi AP-LR"; "U2Lv2"="UniFi AP-LR v2"; "U2M"="UniFi AP-Mini"; "U2O"="UniFi AP-Outdoor"; "U2S48"="UniFi AP"; "U2Sv2"="UniFi AP v2"; "U5O"="UniFi AP-Outdoor 5G"; "U7E"="UniFi AP-AC"; "U7EDU"="UniFi AP-AC-EDU"; "U7Ev2"="UniFi AP-AC v2"; "U7HD"="UniFi AP-HD"; "U7SHD"="UniFi AP-SHD"; "U7NHD"="UniFi AP-nanoHD"; "UFLHD"="UniFi AP-Flex-HD"; "UHDIW"="UniFi AP-HD-In Wall"; "UCXG"="UniFi AP-XG"; "UXSDM"="UniFi AP-BaseStationXG"; "UCMSH"="UniFi AP-MeshXG"; "U7IW"="UniFi AP-AC-In Wall"; "U7IWP"="UniFi AP-AC-In Wall Pro"; "U7MP"="UniFi AP-AC-Mesh-Pro"; "U7LR"="UniFi AP-AC-LR"; "U7LT"="UniFi AP-AC-Lite"; "U7O"="UniFi AP-AC Outdoor"; "U7P"="UniFi AP-Pro"; "U7MSH"="UniFi AP-AC-Mesh"; "U7PG2"="UniFi AP-AC-Pro"; "p2N"="PicoStation M2"; "US48PRO"="UniFi Switch Pro 48"; "US8"="UniFi Switch 8"; "US8P60"="UniFi Switch 8 POE-60W"; "US8P150"="UniFi Switch 8 POE-150W"; "S28150"="UniFi Switch 8 AT-150W"; "USC8"="UniFi Switch 8"; "US16P150"="UniFi Switch 16 POE-150W"; "S216150"="UniFi Switch 16 AT-150W"; "US24"="UniFi Switch 24"; "US24P250"="UniFi Switch 24 POE-250W"; "US24PL2"="UniFi Switch 24 L2 POE"; "US24P500"="UniFi Switch 24 POE-500W"; "S224250"="UniFi Switch 24 AT-250W"; "S224500"="UniFi Switch 24 AT-500W"; "US48"="UniFi Switch 48"; "US48P500"="UniFi Switch 48 POE-500W"; "US48PL2"="UniFi Switch 48 L2 POE"; "US48P750"="UniFi Switch 48 POE-750W"; "S248500"="UniFi Switch 48 AT-500W"; "S248750"="UniFi Switch 48 AT-750W"; "US6XG150"="UniFi Switch 6XG POE-150W"; "USXG"="UniFi Switch 16XG"; "UGW3"="UniFi Security Gateway 3P"; "UGW4"="UniFi Security Gateway 4P"; "UGWHD4"="UniFi Security Gateway HD"; "UGWXG"="UniFi Security Gateway XG-8"; "UP4"="UniFi Phone-X"; "UP5"="UniFi Phone"; "UP5t"="UniFi Phone-Pro"; "UP7"="UniFi Phone-Executive"; "UP5c"="UniFi Phone"; "UP5tc"="UniFi Phone-Pro"; "UP7c"="UniFi Phone-Executive";
		"UDMPRO"="UniFi Dream Machine Pro"}

		# LOOP THROUGH DEVICES FOR SITE
		Foreach ($device in ($devices.data)){
			$DeviceModelName = $DeviceModelNames[$($device.model)]
			$DeviceStateName = $DeviceStateNames[$device.state]

			if( $device.default -eq $True){	# PENDING ADOPTION:
				$vwireEnabled = ($device.vwireEnabled -eq $True)
				$discovered_via = $device.discovered_via	#scan or l2
				
				$FullRpt += "* $($device.type) $DeviceModelName, ip:$($device.ip), mac:$($device.mac), state:$DeviceStateName $($device.state), discovered_via:$discovered_via, wireless:$vwireEnabled`r`n"				
				$rptDevices += "* PENDING ADOPTION$(if($vwireEnabled){" (WIRELESS)"}): $shortsitename > ($DeviceModelName)`r`n"
				$exitcode = 1010

			}elseif ($device.adopted -eq $True){
				if($device.state -eq 0 -or $device.state -eq 9 -or $device.state -eq 11){	# DISCONNECTED, INFORM ERROR, ISOLATED:
					$FullRpt += "* '$($device.NAME)' - $($device.type) $DeviceModelName, ip:$($device.ip), mac:$($device.mac),`r`n    state:$($device.state) $DeviceStateName, adopted:$($device.adopted), disabled:$($device.disabled -eq $True)`r`n"				
					if($device.disabled -ne $True){	# don't care if it's disabled
						$rptDevices += "* $($DeviceStateName.toUpper()): $shortsitename > '$($device.name)'`r`n"
						$exitcode = 1011
					}	
	
				}else{	# OTHERWISE:					
					$uptime = new-TimeSpan -Seconds (0+ $device.'system-stats'.uptime)

					$FullRpt += "* '$($device.NAME)' - $($device.type) $DeviceModelName, ip:$($device.ip), mac:$($device.mac),`r`n    state:$($device.state) $DeviceStateName, uptime:$uptime, cpu:$([int]$device.'system-stats'.cpu)%, mem:$([int]$device.'system-stats'.mem)%, current:$(!$device.upgradable) (FW v$($device.version))`r`n"

					if( [math]::Round($device.'system-stats'.uptime) -lt "300") { 
						$rptDevices += "* $shortsitename > $($device.name) : Disconnected `r`n"
						$exitcode = 1010
					}
					if( [math]::Round($device.'system-stats'.cpu) -gt "90.0") { 
						$rptDevices += "* $shortsitename > $($device.name) : CPU usage of $($device.'system-stats'.cpu)% `r`n"
						$exitcode = 1012
					}
					if( [math]::Round($device.'system-stats'.mem) -gt "90.0") { 
						$rptDevices += "* $shortsitename > $($device.name) : Memory usage of $($device.'system-stats'.mem)% `r`n"
						$exitcode = 1013
					}
					if( $device.upgradable -eq $true ) {
	#					$rptDevices += "* $shortsitename > $($device.name) : Firmware upgrade available <-- IGNORING til QA recovers`r`n"
	#					$exitcode = 1014
					}

					#PORTS:
					#if($device.port_table.count -gt 0){	#don't need to see AP Pros' secondary ports
					if($device.type -eq 'usw'){
						$FullRpt += "  "+ ($device.port_table |ft port_idx,name,enable,up,is_uplink,port_poe,autoneg,speed,full_duplex,network_name -AutoSize |out-string).trim().replace("`n", "`n  ") +"`r`n"
					}
					Foreach ($port in $device.port_table.data){
						if($port.stp_state -eq "discard"){
							$rptDevices += "* $shortsitename > $($device.desc) > PORT $($device.name) : blocked due to STP issues `r`n" 
							$exitcode = 1017
						}
					}
		
				}

			}
			
		}

		Foreach ($device in $devices.data.wan1 |where-object {$_.name}){	#weirdly duplicates with blanks otherwise
			$FullRpt += "* FW WAN1 '$($device.name)' - is_uplink:$($device.is_uplink), up:$($device.up), ip:$($device.ip), netmask:$($device.netmask), gateway:$($device.gateway)  `r`n"
			if($device.is_uplink -and $device.up -ne $True) { 
				$rptDevices += "* $shortsitename > WAN1 $($device.name) : link down `r`n" 
				$exitcode = 1015
			}
		}
		Foreach ($device in $devices.data.wan2 |where-object {$_.name}){#weirdly duplicates with blanks otherwise
			$FullRpt += "* FW WAN2 '$($device.name)' - is_uplink:$($device.is_uplink), up:$($device.up), ip:$($device.ip), netmask:$($device.netmask), gateway:$($device.gateway)  `r`n"
			if($device.is_uplink -and $device.up -ne $True) { 
				$rptDevices += "* $shortsitename > WAN2 $($device.name) : link down`r`n" 
				$exitcode = 1016
			}
		}

		
		# DIRTY HACK: UDMPs lose their auth on the second call (Something ServerCertificateValidationCallback related), so just pound it back every time:
		Invoke-RestMethod -uri $LoginURI -Method POST -Body $Credential -ContentType "application/json" -SessionVariable UniFiSession | out-null

		try {
			$urlAlarms = "$($BaseURI)/api/s/$($site.name)/stat/alarm/"
			$alarms = Invoke-Restmethod -Uri $urlAlarms -Method GET -ContentType "application/json" -Headers @{"Accept"="application/json"} -WebSession $UniFiSession
		}catch{
			$report += "* ERROR - Alarm Query Failed: $($_.Exception.Message)"
			write-error "* ERROR - Alarm Query Failed: $($_.Exception.Message)"
		}
		Foreach ($alarm in ($alarms.data)){
			if(! $($alarm.handled_time)) {	# ???
				$rptAlarms += "* ALERT: $shortsitename > '$($alarm.ap_name)' : $($alarm.msg) @$([datetime]$alarm.datetime -f "yyyy-MM-dd-Hmmss") `r`n" 
				$exitcode = 1018
			} 
		}

	}
	
}


if($rptDevices){
	$report += "$rptDevices `r`n"
}
if($rptAlarms){
	$report += "$rptAlarms `r`n"
}


# REPORT PASS/FAIL
if($exitcode -gt 0){
	write-host "FAIL - problems found:"
}else{
	write-host "OK - no problems found!"
}
$report


# REPORT REST:
 write-host "CHECKED DATA:"
 write-host " "($FullRpt.replace("`n", "`n  ")).trim() #format


write-host 
write-host "PARAMETERS:"
foreach ( $key in ((Get-Command -Name $MyInvocation.InvocationName).Parameters).Keys ) {
	$val = ((Get-Variable $key -ea SilentlyContinue).Value) -join ", "
	Write-Host "  $($key): $val" 
}
	
write-host
write-host "CONTEXT:"
write-host "  Script Path:" (get-item $MyInvocation.InvocationName)
write-host "  Script Last Updated:" (get-item $MyInvocation.InvocationName).LastWriteTime " (try -5hrs [SW saves as UTC])"
write-host "  Execution Time: Total $($elapsed_Total)sec"


# WRAP UP:
write-host 
write-host "exitcode: $exitcode" 
exit $exitcode