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
}

View File

@@ -0,0 +1,34 @@
function Disconnect-UnifiController {
[CmdletBinding()]
param()
if (-not $script:UnifiSession -or -not $script:UnifiConfig) {
Write-Warning "Not currently connected to a controller."
return
}
$baseUrl = $script:UnifiConfig.ControllerUrl
$params = @{
Method = 'POST'
Uri = "$baseUrl/api/logout"
WebSession = $script:UnifiSession
}
if ($script:UnifiConfig.SkipCertificateCheck) {
$params.SkipCertificateCheck = $true
}
try {
Invoke-RestMethod @params | Out-Null
}
catch {
# Session may already be expired — clear local state regardless
Write-Verbose "Logout request failed (session may have already expired): $_"
}
$script:UnifiSession = $null
$script:UnifiConfig = $null
Write-Host "Disconnected." -ForegroundColor Yellow
}

View File

@@ -0,0 +1,31 @@
function Get-UnifiClient {
[CmdletBinding()]
param(
[string]$Site,
# Limit to currently connected clients only (default returns all known clients)
[switch]$Active
)
if (-not $script:UnifiSession) {
Write-Error "Not connected. Run Connect-UnifiController first."
return
}
$siteId = Resolve-UnifiSite $Site
$endpoint = if ($Active) { '/stat/sta' } else { '/stat/alluser' }
$result = Invoke-UnifiRequest -Endpoint $endpoint -Site $siteId
if (-not $result.data -or $result.data.Count -eq 0) {
Write-Warning "No clients found on site '$siteId'."
return
}
$result.data | Select-Object `
@{ N = 'Hostname'; E = { if ($_.hostname) { $_.hostname } elseif ($_.name) { $_.name } else { '-' } } },
@{ N = 'IP'; E = { if ($_.ip) { $_.ip } elseif ($_.fixed_ip) { "$($_.fixed_ip)*" } else { '-' } } },
@{ N = 'MAC'; E = { $_.mac } },
@{ N = 'Network'; E = { if ($_.essid) { $_.essid } elseif ($_.network) { $_.network } else { '-' } } },
@{ N = 'Type'; E = { if ($_.is_wired) { 'Wired' } else { 'Wireless' } } },
@{ N = 'Signal'; E = { if (-not $_.is_wired -and $_.rssi) { "$($_.signal) dBm (RSSI $($_.rssi))" } else { '-' } } }
}

View File

@@ -0,0 +1,25 @@
function Get-UnifiConnectionStatus {
[CmdletBinding()]
param()
if (-not $script:UnifiSession -or -not $script:UnifiConfig) {
Write-Host "Status: Not connected" -ForegroundColor Red
Write-Host ""
Write-Host "Run: Connect-UnifiController <host> or unifi-cli connect <host>"
return
}
Write-Host "Status: " -NoNewline -ForegroundColor White
Write-Host "Connected" -ForegroundColor Green
Write-Host "Controller: $($script:UnifiConfig.ControllerUrl)"
Write-Host "Username: $($script:UnifiConfig.Username)"
Write-Host "Connected: $($script:UnifiConfig.ConnectedAt)"
Write-Host "Site: " -NoNewline -ForegroundColor White
Write-Host $script:UnifiConfig.DefaultSite -ForegroundColor Cyan
Write-Host " (change with: unifi-cli use site <id>)" -ForegroundColor DarkGray
if ($script:UnifiConfig.SkipCertificateCheck) {
Write-Host "TLS: " -NoNewline -ForegroundColor White
Write-Host "Certificate check disabled" -ForegroundColor Yellow
}
}

View File

@@ -0,0 +1,41 @@
function Get-UnifiDevice {
[CmdletBinding()]
param(
[string]$Site
)
if (-not $script:UnifiSession) {
Write-Error "Not connected. Run Connect-UnifiController first."
return
}
$siteId = Resolve-UnifiSite $Site
$result = Invoke-UnifiRequest -Endpoint '/stat/device' -Site $siteId
if (-not $result.data -or $result.data.Count -eq 0) {
Write-Warning "No devices found on site '$siteId'."
return
}
$result.data | Select-Object `
@{ N = 'Name'; E = { if ($_.name) { $_.name } else { $_.mac } } },
@{ N = 'Type'; E = { switch ($_.type) {
'uap' { 'Access Point' }
'usw' { 'Switch' }
'ugw' { 'Gateway' }
'udm' { 'Dream Machine'}
default { $_.type }
} } },
@{ N = 'Model'; E = { $_.model } },
@{ N = 'IP'; E = { $_.ip } },
@{ N = 'MAC'; E = { $_.mac } },
@{ N = 'State'; E = { switch ($_.state) {
0 { 'Disconnected' }
1 { 'Connected' }
2 { 'Pending' }
4 { 'Upgrading' }
5 { 'Provisioning' }
default { "State $($_.state)" }
} } },
@{ N = 'Version'; E = { $_.version } }
}

21
Public/Get-UnifiSite.ps1 Normal file
View File

@@ -0,0 +1,21 @@
function Get-UnifiSite {
[CmdletBinding()]
param()
if (-not $script:UnifiSession) {
Write-Error "Not connected. Run Connect-UnifiController first."
return
}
$result = Invoke-UnifiRequest -Endpoint '/api/self/sites'
if (-not $result.data -or $result.data.Count -eq 0) {
Write-Warning "No sites returned."
return
}
$result.data | Select-Object `
@{ N = 'SiteId'; E = { $_.name } },
@{ N = 'Description'; E = { $_.desc } },
@{ N = 'Role'; E = { $_.role } }
}

49
Public/Get-UnifiWlan.ps1 Normal file
View File

@@ -0,0 +1,49 @@
function Get-UnifiWlan {
[CmdletBinding()]
param(
[string]$Site,
# Filter by SSID name
[string]$Ssid,
# Include the passphrase in output — omitted by default
[switch]$ShowPassword
)
if (-not $script:UnifiSession) {
Write-Error "Not connected. Run Connect-UnifiController first."
return
}
$siteId = Resolve-UnifiSite $Site
$result = Invoke-UnifiRequest -Endpoint '/rest/wlanconf' -Site $siteId
if (-not $result.data -or $result.data.Count -eq 0) {
Write-Warning "No WLANs found on site '$siteId'."
return
}
$wlans = $result.data
if ($Ssid) {
$wlans = $wlans | Where-Object { $_.name -eq $Ssid }
if (-not $wlans) {
Write-Warning "No WLAN named '$Ssid' found on site '$siteId'."
return
}
}
$props = [System.Collections.Generic.List[object]]@(
@{ N = 'SSID'; E = { $_.name } }
@{ N = 'Enabled'; E = { $_.enabled } }
@{ N = 'Security'; E = { $_.security } }
@{ N = 'Band'; E = { switch ($_.wlan_band) { '2g' {'2.4 GHz'} '5g' {'5 GHz'} 'both' {'Both'} default { $_.wlan_band } } } }
)
if ($ShowPassword) {
$props.Add(@{ N = 'Password'; E = { $_.x_passphrase } })
}
$props.Add(@{ N = 'ID'; E = { $_._id } })
$wlans | Select-Object $props
}

118
Public/Invoke-UnifiCli.ps1 Normal file
View File

@@ -0,0 +1,118 @@
function Invoke-UnifiCli {
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[string]$Command,
[Parameter(Position = 1, ValueFromRemainingArguments)]
[string[]]$Arguments
)
switch ($Command.ToLower()) {
'connect' {
if (-not $Arguments -or $Arguments.Count -eq 0) {
Write-Error "Usage: unifi-cli connect <host> [--port <n>] [--insecure] [--save]"
return
}
$p = @{ Controller = $Arguments[0] }
for ($i = 1; $i -lt $Arguments.Count; $i++) {
switch ($Arguments[$i]) {
'--insecure' { $p.SkipCertificateCheck = $true }
'--save' { $p.Save = $true }
'--port' { $p.Port = [int]$Arguments[++$i] }
'--totp' { $p.Token = $Arguments[++$i] }
}
}
Connect-UnifiController @p
}
'disconnect' {
Disconnect-UnifiController
}
'status' {
Get-UnifiConnectionStatus
}
'use' {
$noun = if ($Arguments.Count -gt 0) { $Arguments[0].ToLower() } else { '' }
switch ($noun) {
'site' {
if ($Arguments.Count -lt 2) {
Write-Error "Usage: unifi-cli use site <id> (get IDs from: unifi-cli list sites)"
return
}
$p = @{ Site = $Arguments[1] }
if ($Arguments -contains '--save') { $p.Save = $true }
Set-UnifiDefaultSite @p
}
default { Write-Warning "Unknown: unifi-cli use $noun. Try: unifi-cli use site <id>" }
}
}
'set' {
$noun = if ($Arguments.Count -gt 0) { $Arguments[0].ToLower() } else { '' }
switch ($noun) {
'wlan-password' {
$p = @{}
for ($i = 1; $i -lt $Arguments.Count; $i++) {
switch ($Arguments[$i]) {
'--ssid' { $p.Ssid = $Arguments[++$i] }
'--site' { $p.Site = $Arguments[++$i] }
}
}
if (-not $p.Ssid) {
Write-Error "Usage: unifi-cli set wlan-password --ssid <name> [--site <id>]"
return
}
Set-UnifiWlanPassword @p
}
default { Write-Warning "Unknown: unifi-cli set $noun. Try: unifi-cli set wlan-password --ssid <name>" }
}
}
{ $_ -in 'list', 'get', 'show' } {
$target = if ($Arguments.Count -gt 0) { $Arguments[0].ToLower() } else { '' }
# Parse shared flags: --site <id>, --active, --show-password
$p = @{}
for ($i = 1; $i -lt $Arguments.Count; $i++) {
switch ($Arguments[$i]) {
'--site' { $p.Site = $Arguments[++$i] }
'--active' { $p.Active = $true }
'--show-password' { $p.ShowPassword = $true }
'--ssid' { $p.Ssid = $Arguments[++$i] }
}
}
switch ($target) {
'sites' { Get-UnifiSite }
'devices' { Get-UnifiDevice @p }
'clients' { Get-UnifiClient @p }
'wlans' { Get-UnifiWlan @p }
default {
Write-Warning "Unknown target '$target'. Available: sites, devices, clients, wlans"
}
}
}
default {
Write-Host ""
Write-Host "UnifiCLI — Unifi Network Controller CLI" -ForegroundColor Cyan
Write-Host ""
Write-Host " unifi-cli connect <host> [--port <n>] [--insecure] [--save] [--totp <code>]"
Write-Host " unifi-cli disconnect"
Write-Host " unifi-cli status"
Write-Host " unifi-cli use site <id> [--save]"
Write-Host " unifi-cli list sites"
Write-Host " unifi-cli list devices [--site <id>]"
Write-Host " unifi-cli list clients [--site <id>] [--active]"
Write-Host " unifi-cli list wlans [--site <id>] [--ssid <name>] [--show-password]"
Write-Host " unifi-cli set wlan-password --ssid <name> [--site <id>]"
Write-Host ""
Write-Host "Native PS functions: Connect-UnifiController, Get-UnifiSite, etc." -ForegroundColor DarkGray
Write-Host ""
}
}
}

View File

@@ -0,0 +1,30 @@
function Set-UnifiDefaultSite {
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[string]$Site,
# Persist the selection to disk so it survives reconnects
[switch]$Save
)
if (-not $script:UnifiSession -or -not $script:UnifiConfig) {
Write-Error "Not connected. Run Connect-UnifiController first."
return
}
$previous = $script:UnifiConfig.DefaultSite
$script:UnifiConfig.DefaultSite = $Site
if ($Save) {
Write-UnifiConfig -Config $script:UnifiConfig
Write-Verbose "Site selection saved to $(Get-UnifiConfigPath)"
}
Write-Host "Default site: " -NoNewline -ForegroundColor White
if ($previous -ne $Site) {
Write-Host "$previous" -NoNewline -ForegroundColor DarkGray
Write-Host " -> " -NoNewline -ForegroundColor DarkGray
}
Write-Host $Site -ForegroundColor Cyan
}

View File

@@ -0,0 +1,38 @@
function Set-UnifiWlanPassword {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$Ssid,
[string]$Site,
# If omitted, a SecureString prompt will appear
[SecureString]$NewPassword
)
if (-not $script:UnifiSession) {
Write-Error "Not connected. Run Connect-UnifiController first."
return
}
$siteId = Resolve-UnifiSite $Site
$result = Invoke-UnifiRequest -Endpoint '/rest/wlanconf' -Site $siteId
$wlan = $result.data | Where-Object { $_.name -eq $Ssid } | Select-Object -First 1
if (-not $wlan) {
Write-Warning "No WLAN named '$Ssid' found on site '$siteId'."
return
}
if (-not $NewPassword) {
$NewPassword = Read-Host -Prompt "New password for '$Ssid'" -AsSecureString
}
$plainText = ConvertFrom-SecureString -SecureString $NewPassword -AsPlainText
if ($PSCmdlet.ShouldProcess("WLAN '$Ssid' on site '$siteId'", 'Set password')) {
Invoke-UnifiRequest -Method PUT -Endpoint "/rest/wlanconf/$($wlan._id)" -Site $siteId -Body @{ x_passphrase = $plainText }
Write-Host "Password updated for '$Ssid' on site '$siteId'." -ForegroundColor Green
}
}