Initial commit. Working and linked on PSGallery

This commit is contained in:
2026-05-15 08:53:21 +08:00
commit 7c77363885
17 changed files with 1109 additions and 0 deletions

View File

@@ -0,0 +1,213 @@
function Connect-UnifiController {
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[string]$Controller,
[int]$Port = 443,
# If omitted, a credential prompt will appear
[PSCredential]$Credential,
# TOTP code for accounts with 2FA enabled.
# If not supplied and the controller requires 2FA, you will be prompted interactively.
[string]$Token,
# Disable TLS certificate validation — required for controllers using self-signed certs
[switch]$SkipCertificateCheck,
# Persist controller URL, username, and TLS setting to disk (password is never saved)
[switch]$Save
)
# Normalize to a full URL
$baseUrl = if ($Controller -match '^https?://') {
$Controller.TrimEnd('/')
} else {
"https://${Controller}:${Port}"
}
if (-not $Credential) {
$Credential = Get-Credential -Message "Unifi credentials for $baseUrl"
if (-not $Credential) {
Write-Warning "No credentials provided."
return
}
}
# -------------------------------------------------------------------------
# Step 1 — credentials only. TOTP never goes here; it belongs in step 2.
# -------------------------------------------------------------------------
$loginBody = @{
username = $Credential.UserName
password = $Credential.GetNetworkCredential().Password
remember = $true
} | ConvertTo-Json -Compress
Write-Verbose "Step 1: POST $baseUrl/api/login (user: $($Credential.UserName))"
$step1Params = @{
Method = 'POST'
Uri = "$baseUrl/api/login"
Body = $loginBody
ContentType = 'application/json'
SessionVariable = 'webSession'
ErrorAction = 'Stop'
}
if ($SkipCertificateCheck) { $step1Params.SkipCertificateCheck = $true }
$response = $null
try {
$response = Invoke-WebRequest @step1Params
}
catch {
$errBody = $null
$serverMsg = $null
if ($_.ErrorDetails.Message) {
try {
$errBody = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Stop
$serverMsg = $errBody.meta.msg
} catch {}
}
Write-Verbose "Step 1 error: $serverMsg"
Write-Verbose "Raw response body: $($_.ErrorDetails.Message)"
if ($serverMsg -eq 'api.err.Ubic2faTokenRequired') {
# ------------------------------------------------------------------
# Step 2 — re-submit credentials + TOTP. The mfa_cookie JWT from Step 1
# is a server-side signal only and is not sent back.
# ------------------------------------------------------------------
$defaultMfaId = $errBody.data[0].default_mfa
$authenticators = $errBody.data[0].authenticators
Write-Verbose "Step 2: 2FA challenge received (default method: $defaultMfaId)"
$totpToken = if ($Token) {
Write-Verbose "Using token supplied via -Token parameter"
$Token
} else {
Write-Host "2FA required. Registered authenticators:" -ForegroundColor Yellow
foreach ($auth in $authenticators) {
$marker = if ($auth.id -eq $defaultMfaId) { ' <-- default' } else { '' }
Write-Host " [$($auth.type.ToUpper())] $($auth.name)$marker"
}
Read-Host "Enter your 2FA code"
}
# Re-send full credentials with the TOTP code appended.
# mfa_cookie is NOT sent back — it was a server signal only.
# strict:true is required by the Ubiquiti SSO flow.
$mfaBody = @{
username = $Credential.UserName
password = $Credential.GetNetworkCredential().Password
remember = $true
strict = $true
ubic_2fa_token = $totpToken
} | ConvertTo-Json -Compress
Write-Verbose "Step 2: POST $baseUrl/api/login (fields: username, password, remember, strict, ubic_2fa_token)"
$step2Params = @{
Method = 'POST'
Uri = "$baseUrl/api/login"
Body = $mfaBody
ContentType = 'application/json'
SessionVariable = 'webSession'
ErrorAction = 'Stop'
}
if ($SkipCertificateCheck) { $step2Params.SkipCertificateCheck = $true }
try {
$response = Invoke-WebRequest @step2Params
}
catch {
$mfaMsg = $null
if ($_.ErrorDetails.Message) {
try {
$mfaMsg = ($_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Stop).meta.msg
} catch {}
}
Write-Error "2FA verification failed$(if ($mfaMsg) { " ($mfaMsg)" }). The code may have expired — try again immediately after reading it from your authenticator."
Write-Verbose "Raw step 2 exception: $_"
return
}
} else {
# Not a 2FA challenge — report the error cleanly
if ($serverMsg) {
switch -Exact ($serverMsg) {
'api.err.Invalid' {
Write-Error "Login failed: invalid credentials for $baseUrl.`nIf using a Ubiquiti cloud account, the username is your SSO email address."
break
}
default {
Write-Error "Login failed ($serverMsg) on $baseUrl."
}
}
} else {
Write-Error "Connection to $baseUrl failed. Run with -Verbose for details."
}
return
}
}
# -------------------------------------------------------------------------
# Both step 1 (no 2FA) and step 2 (2FA complete) land here
# -------------------------------------------------------------------------
$parsed = $response.Content | ConvertFrom-Json
if ($parsed.meta.rc -ne 'ok') {
Write-Error "Login rejected: $($parsed.meta.msg)"
return
}
# Dump all response headers and cookies under -Verbose to aid CSRF debugging
Write-Verbose "Login response headers:"
foreach ($h in $response.Headers.GetEnumerator()) {
Write-Verbose " $($h.Key): $($h.Value -join ', ')"
}
Write-Verbose "Session cookies for $baseUrl :"
foreach ($c in $webSession.Cookies.GetCookies([uri]$baseUrl)) {
$preview = if ($c.Value.Length -gt 60) { $c.Value.Substring(0,60) + '...' } else { $c.Value }
Write-Verbose " $($c.Name) = $preview"
}
# UniFi requires X-Csrf-Token header on all write operations (PUT/POST/DELETE).
# The token is returned at login — check the response header first, then cookies.
$csrfToken = $null
$csrfHeader = $response.Headers['X-Csrf-Token']
if ($csrfHeader) {
$csrfToken = @($csrfHeader)[0]
Write-Verbose "CSRF token found in response header."
}
if (-not $csrfToken) {
$csrfCookie = $webSession.Cookies.GetCookies([uri]$baseUrl) |
Where-Object Name -eq 'csrf_token' |
Select-Object -First 1
if ($csrfCookie) {
$csrfToken = $csrfCookie.Value
Write-Verbose "CSRF token found in 'csrf_token' cookie."
}
}
if (-not $csrfToken) {
Write-Verbose "WARNING: No CSRF token found — write operations may return 401."
}
$script:UnifiSession = $webSession
$script:UnifiConfig = @{
ControllerUrl = $baseUrl
Username = $Credential.UserName
DefaultSite = 'default'
SkipCertificateCheck = $SkipCertificateCheck.IsPresent
ConnectedAt = (Get-Date -Format 'o')
CsrfToken = $csrfToken
}
if ($Save) {
Write-UnifiConfig -Config $script:UnifiConfig
Write-Verbose "Config saved to $(Get-UnifiConfigPath)"
}
Write-Host "Connected to $baseUrl as $($Credential.UserName)" -ForegroundColor Green
}