223 lines
14 KiB
PowerShell
223 lines
14 KiB
PowerShell
#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
|