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

0
CurrentIssues.md Normal file
View File

View 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`: $_"
}
}

View 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
View 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
}

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
}
}

368
TESTING.md Normal file
View 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
View 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
View 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