Initial commit. Working and linked on PSGallery
This commit is contained in:
0
CurrentIssues.md
Normal file
0
CurrentIssues.md
Normal file
58
Private/Invoke-UnifiRequest.ps1
Normal file
58
Private/Invoke-UnifiRequest.ps1
Normal file
@@ -0,0 +1,58 @@
|
||||
function Invoke-UnifiRequest {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('GET', 'POST', 'PUT', 'DELETE')]
|
||||
[string]$Method = 'GET',
|
||||
|
||||
# For site-scoped endpoints pass just the path after /api/s/{site}, e.g. '/rest/wlanconf'
|
||||
# For global endpoints pass the full path, e.g. '/api/self/sites'
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Endpoint,
|
||||
|
||||
[string]$Site,
|
||||
|
||||
[object]$Body
|
||||
)
|
||||
|
||||
if (-not $script:UnifiSession -or -not $script:UnifiConfig) {
|
||||
throw "Not connected. Run Connect-UnifiController first."
|
||||
}
|
||||
|
||||
$baseUrl = $script:UnifiConfig.ControllerUrl
|
||||
|
||||
$uri = if ($Site) {
|
||||
"$baseUrl/api/s/$Site$Endpoint"
|
||||
} else {
|
||||
"$baseUrl$Endpoint"
|
||||
}
|
||||
|
||||
$params = @{
|
||||
Method = $Method
|
||||
Uri = $uri
|
||||
WebSession = $script:UnifiSession
|
||||
}
|
||||
|
||||
if ($script:UnifiConfig.SkipCertificateCheck) {
|
||||
$params.SkipCertificateCheck = $true
|
||||
}
|
||||
|
||||
if ($Method -ne 'GET' -and $script:UnifiConfig.CsrfToken) {
|
||||
$params.Headers = @{ 'X-Csrf-Token' = $script:UnifiConfig.CsrfToken }
|
||||
}
|
||||
|
||||
if ($Body) {
|
||||
$params.Body = ($Body | ConvertTo-Json -Depth 10 -Compress)
|
||||
$params.ContentType = 'application/json'
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-RestMethod @params
|
||||
}
|
||||
catch [Microsoft.PowerShell.Commands.HttpResponseException] {
|
||||
$code = $_.Exception.Response.StatusCode.value__
|
||||
if ($code -eq 401) {
|
||||
throw "Session expired or unauthorized. Run Connect-UnifiController to re-authenticate."
|
||||
}
|
||||
throw "HTTP $code`: $_"
|
||||
}
|
||||
}
|
||||
6
Private/Resolve-UnifiSite.ps1
Normal file
6
Private/Resolve-UnifiSite.ps1
Normal file
@@ -0,0 +1,6 @@
|
||||
function Resolve-UnifiSite {
|
||||
param([string]$Site)
|
||||
if ($Site) { return $Site }
|
||||
if ($script:UnifiConfig.DefaultSite) { return $script:UnifiConfig.DefaultSite }
|
||||
return 'default'
|
||||
}
|
||||
31
Private/UnifiConfig.ps1
Normal file
31
Private/UnifiConfig.ps1
Normal file
@@ -0,0 +1,31 @@
|
||||
function Get-UnifiConfigPath {
|
||||
$dir = Join-Path $env:APPDATA 'UnifiCLI'
|
||||
if (-not (Test-Path $dir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
Join-Path $dir 'config.json'
|
||||
}
|
||||
|
||||
function Read-UnifiConfig {
|
||||
$path = Get-UnifiConfigPath
|
||||
if (Test-Path $path) {
|
||||
Get-Content $path -Raw | ConvertFrom-Json -AsHashtable
|
||||
} else {
|
||||
$null
|
||||
}
|
||||
}
|
||||
|
||||
# Password is intentionally excluded — never written to disk
|
||||
function Write-UnifiConfig {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[hashtable]$Config
|
||||
)
|
||||
$safe = @{
|
||||
ControllerUrl = $Config.ControllerUrl
|
||||
Username = $Config.Username
|
||||
DefaultSite = $Config.DefaultSite
|
||||
SkipCertificateCheck = $Config.SkipCertificateCheck
|
||||
}
|
||||
$safe | ConvertTo-Json | Set-Content -Path (Get-UnifiConfigPath) -Encoding UTF8
|
||||
}
|
||||
213
Public/Connect-UnifiController.ps1
Normal file
213
Public/Connect-UnifiController.ps1
Normal 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
|
||||
}
|
||||
34
Public/Disconnect-UnifiController.ps1
Normal file
34
Public/Disconnect-UnifiController.ps1
Normal 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
|
||||
}
|
||||
31
Public/Get-UnifiClient.ps1
Normal file
31
Public/Get-UnifiClient.ps1
Normal 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 { '-' } } }
|
||||
}
|
||||
25
Public/Get-UnifiConnectionStatus.ps1
Normal file
25
Public/Get-UnifiConnectionStatus.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
41
Public/Get-UnifiDevice.ps1
Normal file
41
Public/Get-UnifiDevice.ps1
Normal 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
21
Public/Get-UnifiSite.ps1
Normal 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
49
Public/Get-UnifiWlan.ps1
Normal 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
118
Public/Invoke-UnifiCli.ps1
Normal 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 ""
|
||||
}
|
||||
}
|
||||
}
|
||||
30
Public/Set-UnifiDefaultSite.ps1
Normal file
30
Public/Set-UnifiDefaultSite.ps1
Normal 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
|
||||
}
|
||||
38
Public/Set-UnifiWlanPassword.ps1
Normal file
38
Public/Set-UnifiWlanPassword.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
368
TESTING.md
Normal file
368
TESTING.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# UnifiCLI — Testing Guide
|
||||
|
||||
## Sideloading the Module
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Confirm PowerShell 7+ is installed (required — Windows PowerShell 5.x is not supported):
|
||||
|
||||
```powershell
|
||||
$PSVersionTable.PSVersion
|
||||
# Major should be 7 or higher
|
||||
```
|
||||
|
||||
If you need to install it: https://aka.ms/powershell
|
||||
|
||||
---
|
||||
|
||||
### Option A — Import for the current session only
|
||||
|
||||
Use this while developing or sharing with colleagues who just want to try it out.
|
||||
No files are copied anywhere; the module is gone when the PS window closes.
|
||||
|
||||
```powershell
|
||||
# Clone or copy the repo, then from the repo root:
|
||||
Import-Module .\UnifiCLI.psd1 -Force
|
||||
|
||||
# Confirm it loaded
|
||||
Get-Module UnifiCLI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Option B — Install to your user module path (persists across sessions)
|
||||
|
||||
This makes `Import-Module UnifiCLI` work from any directory, any window.
|
||||
|
||||
```powershell
|
||||
# Find your user module directory
|
||||
$env:PSModulePath -split ';'
|
||||
# Pick the one under your profile, e.g. C:\Users\<you>\Documents\PowerShell\Modules
|
||||
|
||||
# Copy the whole repo folder there
|
||||
$dest = "$HOME\Documents\PowerShell\Modules\UnifiCLI"
|
||||
Copy-Item -Path 'C:\path\to\UnifiCLI' -Destination $dest -Recurse -Force
|
||||
|
||||
# Now load it from anywhere
|
||||
Import-Module UnifiCLI -Force
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```powershell
|
||||
Remove-Item "$HOME\Documents\PowerShell\Modules\UnifiCLI" -Recurse -Force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Verify the module loaded correctly
|
||||
|
||||
```powershell
|
||||
Get-Command -Module UnifiCLI
|
||||
# Should list: Connect-UnifiController, Disconnect-UnifiController,
|
||||
# Get-UnifiConnectionStatus, Get-UnifiSite, Get-UnifiDevice,
|
||||
# Get-UnifiClient, Get-UnifiWlan, Set-UnifiWlanPassword, Invoke-UnifiCli
|
||||
|
||||
Get-Alias unifi-cli
|
||||
# Should resolve to Invoke-UnifiCli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Connect / Disconnect / Status
|
||||
|
||||
### 1.1 Help output (no arguments)
|
||||
|
||||
```powershell
|
||||
unifi-cli
|
||||
```
|
||||
|
||||
**Expected:** Usage block listing all available commands. No errors.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Status when not connected
|
||||
|
||||
```powershell
|
||||
unifi-cli status
|
||||
# or
|
||||
Get-UnifiConnectionStatus
|
||||
```
|
||||
|
||||
**Expected:** Red "Not connected" message with a hint to run `connect`.
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Connect — credential prompt
|
||||
|
||||
```powershell
|
||||
unifi-cli connect 192.168.1.1 --insecure
|
||||
```
|
||||
|
||||
**Expected:** A credential prompt appears. After entering valid admin credentials, output shows:
|
||||
```
|
||||
Connected to https://192.168.1.1:443 as <username>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Connect — inline credential (useful for scripting, not recommended for shared sessions)
|
||||
|
||||
```powershell
|
||||
$cred = Get-Credential
|
||||
Connect-UnifiController -Controller 192.168.1.1 -Credential $cred -SkipCertificateCheck
|
||||
```
|
||||
|
||||
**Expected:** Same green "Connected" output without a second prompt.
|
||||
|
||||
---
|
||||
|
||||
### 1.5 Connect with non-default port
|
||||
|
||||
```powershell
|
||||
unifi-cli connect 192.168.1.1 --port 8443 --insecure
|
||||
```
|
||||
|
||||
**Expected:** Connects to `https://192.168.1.1:8443`. URL shown in status reflects the port.
|
||||
|
||||
---
|
||||
|
||||
### 1.6 Status when connected
|
||||
|
||||
```powershell
|
||||
unifi-cli status
|
||||
```
|
||||
|
||||
**Expected:** All fields populated — Controller, Username, Connected timestamp, Site (default).
|
||||
If `--insecure` was used, a yellow TLS warning line should appear.
|
||||
|
||||
---
|
||||
|
||||
### 1.7 Connect with --save
|
||||
|
||||
```powershell
|
||||
unifi-cli connect 192.168.1.1 --insecure --save
|
||||
```
|
||||
|
||||
**Expected:** Connects successfully. Verify the config file was written (password must NOT appear):
|
||||
|
||||
```powershell
|
||||
Get-Content "$env:APPDATA\UnifiCLI\config.json"
|
||||
# Should show ControllerUrl, Username, SkipCertificateCheck — no password field
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.8 Disconnect
|
||||
|
||||
```powershell
|
||||
unifi-cli disconnect
|
||||
```
|
||||
|
||||
**Expected:** Yellow "Disconnected." message.
|
||||
|
||||
---
|
||||
|
||||
### 1.9 Status after disconnect
|
||||
|
||||
```powershell
|
||||
unifi-cli status
|
||||
```
|
||||
|
||||
**Expected:** Back to red "Not connected" state.
|
||||
|
||||
---
|
||||
|
||||
### 1.10 Disconnect when not connected
|
||||
|
||||
```powershell
|
||||
unifi-cli disconnect
|
||||
```
|
||||
|
||||
**Expected:** Warning saying "Not currently connected to a controller." — no crash.
|
||||
|
||||
---
|
||||
|
||||
### 1.11 Wrong password
|
||||
|
||||
```powershell
|
||||
Connect-UnifiController -Controller 192.168.1.1 -SkipCertificateCheck `
|
||||
-Credential (New-Object PSCredential('admin', (ConvertTo-SecureString 'wrongpassword' -AsPlainText -Force)))
|
||||
```
|
||||
|
||||
**Expected:** Error message from the controller (e.g. "Login rejected by controller: Invalid credentials").
|
||||
Session must NOT be set — verify with `unifi-cli status` showing "Not connected".
|
||||
|
||||
---
|
||||
|
||||
### 1.12 Unreachable host
|
||||
|
||||
```powershell
|
||||
unifi-cli connect 10.255.255.1 --insecure
|
||||
```
|
||||
|
||||
**Expected:** Connection error (timeout or refused). No crash, no partial session state.
|
||||
Verify `unifi-cli status` still shows "Not connected".
|
||||
|
||||
---
|
||||
|
||||
### 1.13 Phase 2 stub — should not crash
|
||||
|
||||
```powershell
|
||||
# Connect first (1.3 or 1.4), then:
|
||||
unifi-cli list sites
|
||||
```
|
||||
|
||||
**Expected:** Yellow warning: "Get-UnifiSite is not yet implemented (Phase 2)." — no error.
|
||||
|
||||
---
|
||||
|
||||
### 1.14 Phase 2 stub — not connected
|
||||
|
||||
```powershell
|
||||
# Without connecting first:
|
||||
unifi-cli list sites
|
||||
```
|
||||
|
||||
**Expected:** Error: "Not connected. Run Connect-UnifiController first."
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Read Commands *(complete once Phase 2 is built)*
|
||||
|
||||
### 2.1 List all sites
|
||||
|
||||
```powershell
|
||||
unifi-cli list sites
|
||||
# or
|
||||
Get-UnifiSite
|
||||
```
|
||||
|
||||
**Expected:** Table or list of sites with at minimum Site ID and Description columns.
|
||||
Multi-site controllers should show multiple rows. Single-site will show just `default`.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 List devices
|
||||
|
||||
```powershell
|
||||
unifi-cli list devices
|
||||
# or
|
||||
Get-UnifiDevice -Site default
|
||||
```
|
||||
|
||||
**Expected:** List of adopted Unifi devices (APs, switches, gateways) with name, model, MAC, and state.
|
||||
|
||||
---
|
||||
|
||||
### 2.3 List active clients
|
||||
|
||||
```powershell
|
||||
unifi-cli list clients
|
||||
# or
|
||||
Get-UnifiClient -Site default -Active
|
||||
```
|
||||
|
||||
**Expected:** List of currently connected clients with hostname, IP, MAC, SSID or port.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 List WLANs
|
||||
|
||||
```powershell
|
||||
unifi-cli list wlans
|
||||
# or
|
||||
Get-UnifiWlan -Site default
|
||||
```
|
||||
|
||||
**Expected:** Table of SSIDs with name, enabled state, security mode.
|
||||
Passwords should NOT appear in default output.
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Filter WLAN by SSID name
|
||||
|
||||
```powershell
|
||||
Get-UnifiWlan -Site default -Ssid 'georgiou-guest'
|
||||
```
|
||||
|
||||
**Expected:** Returns only the matching SSID entry.
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Non-default site
|
||||
|
||||
```powershell
|
||||
Get-UnifiDevice -Site <site-id-from-2.1>
|
||||
```
|
||||
|
||||
**Expected:** Devices scoped to that site, not all sites combined.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Write Commands *(complete once Phase 3 is built)*
|
||||
|
||||
### 3.1 Rotate password — single site, prompt
|
||||
|
||||
```powershell
|
||||
Set-UnifiWlanPassword -Ssid 'georgiou-guest' -Site default
|
||||
```
|
||||
|
||||
**Expected:** Secure password prompt appears. After entry, success message.
|
||||
Verify by running `Get-UnifiWlan -Ssid 'georgiou-guest'` and confirming the new password.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Rotate password — inline SecureString
|
||||
|
||||
```powershell
|
||||
$newPass = ConvertTo-SecureString 'NewP@ss99' -AsPlainText -Force
|
||||
Set-UnifiWlanPassword -Ssid 'georgiou-guest' -Site default -NewPassword $newPass
|
||||
```
|
||||
|
||||
**Expected:** Password updated without prompting.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 WhatIf — no change should be made
|
||||
|
||||
```powershell
|
||||
Set-UnifiWlanPassword -Ssid 'georgiou-guest' -Site default -WhatIf
|
||||
```
|
||||
|
||||
**Expected:** Output describes what would happen. No API call is made. Password unchanged.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Rotate across all sites
|
||||
|
||||
```powershell
|
||||
Get-UnifiSite | ForEach-Object {
|
||||
Set-UnifiWlanPassword -Ssid 'georgiou-guest' -Site $_.Name -NewPassword $newPass
|
||||
}
|
||||
```
|
||||
|
||||
**Expected:** Each site processed in sequence. Sites without the target SSID report "not found" rather than erroring.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 SSID not found on site
|
||||
|
||||
```powershell
|
||||
Set-UnifiWlanPassword -Ssid 'ssid-that-does-not-exist' -Site default
|
||||
```
|
||||
|
||||
**Expected:** Clear message that the SSID was not found. No error thrown, no partial update.
|
||||
|
||||
---
|
||||
|
||||
## Regression Checks (run after any phase)
|
||||
|
||||
| Check | Command | Pass condition |
|
||||
|---|---|---|
|
||||
| Module loads | `Import-Module .\UnifiCLI.psd1 -Force` | No errors |
|
||||
| All commands present | `Get-Command -Module UnifiCLI` | 9 functions listed |
|
||||
| Alias works | `Get-Alias unifi-cli` | Resolves to `Invoke-UnifiCli` |
|
||||
| Help renders | `unifi-cli` | Usage block with no errors |
|
||||
| Status clean | `unifi-cli status` (not connected) | "Not connected" — no exceptions |
|
||||
| Config has no password | `Get-Content "$env:APPDATA\UnifiCLI\config.json"` | No `password` key present |
|
||||
33
UnifiCLI.psd1
Normal file
33
UnifiCLI.psd1
Normal file
@@ -0,0 +1,33 @@
|
||||
@{
|
||||
ModuleVersion = '1.0.0'
|
||||
GUID = '7b2e4f1a-3c8d-4e9b-a5f2-1d6c8e3b0f4a'
|
||||
Author = 'Bailey Taylor'
|
||||
CompanyName = ''
|
||||
Copyright = ''
|
||||
Description = 'PowerShell module for managing Unifi Network Controller'
|
||||
PowerShellVersion = '7.0'
|
||||
RootModule = 'UnifiCLI.psm1'
|
||||
|
||||
FunctionsToExport = @(
|
||||
'Connect-UnifiController'
|
||||
'Disconnect-UnifiController'
|
||||
'Get-UnifiConnectionStatus'
|
||||
'Get-UnifiSite'
|
||||
'Get-UnifiDevice'
|
||||
'Get-UnifiClient'
|
||||
'Get-UnifiWlan'
|
||||
'Set-UnifiWlanPassword'
|
||||
'Set-UnifiDefaultSite'
|
||||
'Invoke-UnifiCli'
|
||||
)
|
||||
|
||||
AliasesToExport = @('unifi-cli')
|
||||
|
||||
PrivateData = @{
|
||||
PSData = @{
|
||||
Tags = @('Unifi', 'Network', 'CLI', 'Controller')
|
||||
ProjectUri = ''
|
||||
ReleaseNotes = '1.0.0 - Initial release'
|
||||
}
|
||||
}
|
||||
}
|
||||
13
UnifiCLI.psm1
Normal file
13
UnifiCLI.psm1
Normal file
@@ -0,0 +1,13 @@
|
||||
# Module-scoped session state — shared across all dot-sourced functions
|
||||
$script:UnifiSession = $null
|
||||
$script:UnifiConfig = $null
|
||||
|
||||
# Load private helpers
|
||||
Get-ChildItem -Path "$PSScriptRoot\Private\*.ps1" -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { . $_.FullName }
|
||||
|
||||
# Load public functions
|
||||
Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1" -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { . $_.FullName }
|
||||
|
||||
Set-Alias -Name 'unifi-cli' -Value Invoke-UnifiCli -Scope Global
|
||||
Reference in New Issue
Block a user