#region Connect-UnifiController Function Connect-UnifiController { <# .SYNOPSIS Establishes a connection context for a UniFi Network controller, preferring the network interface and degrading to the integration API. .DESCRIPTION UniFi presents the same shape of problem that Omada does. Two transports exist and they are not equivalent. The network interface is what the UniFi Network application itself consumes. It exposes the complete data set including per port media and LLDP neighbour tables, uplink relationships carrying the remote port, network definitions with their subnets, and the real wireless configuration. It is authenticated with a controller login and is not a published contract. The integration API, introduced alongside the API key feature, is a published and versioned contract authenticated with a key rather than a session. It is stable but narrower, covering sites, devices, and clients without the port or topology detail. Two deployment styles are handled. A UniFi OS host, which covers the Dream Machine family, Cloud Key Gen2, and UniFi OS Server, authenticates at /api/auth/login and proxies the network application beneath /proxy/network. A self hosted controller of the older style authenticates at /api/login and serves the network application from the root. The style is detected rather than configured, because the same settings document should work against either. .PARAMETER BaseURL The base URL of the UniFi controller, for example https://unifi.example.com or https://unifi.example.com:8443 .PARAMETER Credential The controller login used for the preferred transport. When omitted, the integration API is used directly. .PARAMETER APIKey The integration API key used for the fallback transport, created under Settings, Control Plane, Integrations. .PARAMETER SkipCertificateCheck Ignores server certificate validation errors. Usually required because controllers present a self signed certificate. .EXAMPLE $UnifiContext = Connect-UnifiController -BaseURL 'https://unifi.example.com' -Credential $Credential -APIKey $APIKey -SkipCertificateCheck .NOTES Author: Grace Solutions Version: 2026.08.02.0000 #> [CmdletBinding()] Param ( [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [System.URI]$BaseURL, [Parameter(Mandatory=$False)] [AllowNull()] [System.Management.Automation.PSCredential]$Credential, [Parameter(Mandatory=$False)] [AllowEmptyString()] [String]$APIKey = '', [Parameter(Mandatory=$False)] [Switch]$SkipCertificateCheck ) Try { $BaseAddress = "$("$($BaseURL.AbsoluteUri)".TrimEnd('/'))" $ContextProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' $ContextProperties.BaseAddress = "$($BaseAddress)" $ContextProperties.SkipCertificateCheck = $SkipCertificateCheck.IsPresent $ContextProperties.Mode = 'None' $ContextProperties.IsUnifiOS = $False $ContextProperties.NetworkPrefix = '' $ContextProperties.ControllerHeader = $Null $ContextProperties.IntegrationHeader = $Null $ContextProperties.ControllerVersion = '' #region Preferred transport, which is the network interface behind a controller session Switch ($Null -ine $Credential) { {($_ -eq $True)} { $LoginBody = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' $LoginBody.username = "$($Credential.UserName)" $LoginBody.password = "$($Credential.GetNetworkCredential().Password)" #region A UniFi OS host authenticates at one path and a self hosted controller at another $LoginCandidateList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]' $UnifiOSCandidate = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' $UnifiOSCandidate.Path = '/api/auth/login' $UnifiOSCandidate.IsUnifiOS = $True $UnifiOSCandidate.NetworkPrefix = '/proxy/network' $SelfHostedCandidate = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' $SelfHostedCandidate.Path = '/api/login' $SelfHostedCandidate.IsUnifiOS = $False $SelfHostedCandidate.NetworkPrefix = '' $LoginCandidateList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($UnifiOSCandidate))) $LoginCandidateList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($SelfHostedCandidate))) #endregion :LoginCandidateLoop For ($LoginCandidateIndex = 0; $LoginCandidateIndex -lt $LoginCandidateList.Count; $LoginCandidateIndex++) { $LoginCandidate = $LoginCandidateList[$LoginCandidateIndex] $LoginResponse = Invoke-RestAPIRequest -URI ([System.URI]"$($BaseAddress)$($LoginCandidate.Path)") -Method 'POST' -Body ($LoginBody) -SkipCertificateCheck:($SkipCertificateCheck.IsPresent) -AcceptableStatusCodeList @('*') #region A successful sign in is one that returns a session cookie, whatever else it reports $SessionCookieList = New-Object -TypeName 'System.Collections.Generic.List[String]' ForEach ($SetCookieValue In "$($LoginResponse.ResponseHeader['Set-Cookie'])".Split(';,', [System.StringSplitOptions]::RemoveEmptyEntries)) { Switch ("$($SetCookieValue)".Trim() -imatch '^(TOKEN|unifises|csrf_token)=') { {($_ -eq $True)} { $SessionCookieList.Add("$($SetCookieValue)".Trim()) } } } #endregion Switch (($LoginResponse.StatusCode -ge 200) -and ($LoginResponse.StatusCode -lt 300) -and ($SessionCookieList.Count -gt 0)) { {($_ -eq $True)} { $ControllerHeader = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' $ControllerHeader.Accept = 'application/json' $ControllerHeader.Cookie = "$($SessionCookieList -Join '; ')" #region The cross site request token is echoed back when the host supplies one $CrossSiteToken = "$($LoginResponse.ResponseHeader['X-CSRF-Token'])" Switch ([String]::IsNullOrWhiteSpace($CrossSiteToken) -eq $False) { {($_ -eq $True)} { $ControllerHeader.'X-CSRF-Token' = "$($CrossSiteToken)" } } #endregion $ContextProperties.ControllerHeader = $ControllerHeader $ContextProperties.IsUnifiOS = $LoginCandidate.IsUnifiOS $ContextProperties.NetworkPrefix = "$($LoginCandidate.NetworkPrefix)" $ContextProperties.Mode = 'Controller' Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [INFO] - Signed in to the UniFi network interface as `"$($Credential.UserName)`". [Deployment: $(Switch ($LoginCandidate.IsUnifiOS) {{($_ -eq $True)} {'UniFi OS host'} Default {'self hosted controller'}})]" Break LoginCandidateLoop } } } Switch ($ContextProperties.Mode -ieq 'None') { {($_ -eq $True)} { Write-Warning -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [WARNING] - The UniFi controller sign in failed at every known path, so collection will fall back to the integration API." } } } } #endregion #region Fallback transport, which is the integration API Switch ([String]::IsNullOrWhiteSpace($APIKey) -eq $False) { {($_ -eq $True)} { $IntegrationHeader = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' $IntegrationHeader.'X-API-KEY' = "$($APIKey)" $IntegrationHeader.Accept = 'application/json' $ContextProperties.IntegrationHeader = $IntegrationHeader Switch ($ContextProperties.Mode -ieq 'None') { {($_ -eq $True)} { $ContextProperties.Mode = 'Integration' #region Without a session the deployment style was never established, so it is probed $IntegrationProbe = Invoke-RestAPIRequest -URI ([System.URI]"$($BaseAddress)/proxy/network/integration/v1/sites") -Method 'GET' -Header ($IntegrationHeader) -SkipCertificateCheck:($SkipCertificateCheck.IsPresent) -AcceptableStatusCodeList @('*') $ContextProperties.IsUnifiOS = ($IntegrationProbe.StatusCode -ge 200) -and ($IntegrationProbe.StatusCode -lt 300) $ContextProperties.NetworkPrefix = Switch ($ContextProperties.IsUnifiOS) {{($_ -eq $True)} {'/proxy/network'} Default {''}} #endregion } } Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [INFO] - A UniFi integration API key is available and remains in reserve." } } #endregion Switch ($ContextProperties.Mode -ieq 'None') { {($_ -eq $True)} { Throw "No usable UniFi transport could be established against `"$($BaseAddress)`". Supply either a controller login or an integration API key." } } #region Record the controller version, which is informational and must never fail the run $VersionResponse = Invoke-RestAPIRequest -URI ([System.URI]"$($BaseAddress)$($ContextProperties.NetworkPrefix)/status") -Method 'GET' -Header ($ContextProperties.ControllerHeader) -SkipCertificateCheck:($SkipCertificateCheck.IsPresent) -AcceptableStatusCodeList @('*') -MaximumRetryCount 0 $ContextProperties.ControllerVersion = "$($VersionResponse.Content.meta.server_version)" #endregion Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [INFO] - UniFi collection will use the $(Switch ($ContextProperties.Mode) {{($_ -ieq 'Controller')} {'network interface, with the integration API held in reserve'} Default {'integration API'}})." Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($ContextProperties)) } Catch { Throw } } #endregion