commit 7c77363885ab18736e2437f875768e553282c748 Author: Bailey Taylor Date: Fri May 15 08:53:21 2026 +0800 Initial commit. Working and linked on PSGallery diff --git a/CurrentIssues.md b/CurrentIssues.md new file mode 100644 index 0000000..e69de29 diff --git a/Private/Invoke-UnifiRequest.ps1 b/Private/Invoke-UnifiRequest.ps1 new file mode 100644 index 0000000..341eff8 --- /dev/null +++ b/Private/Invoke-UnifiRequest.ps1 @@ -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`: $_" + } +} diff --git a/Private/Resolve-UnifiSite.ps1 b/Private/Resolve-UnifiSite.ps1 new file mode 100644 index 0000000..a917b0a --- /dev/null +++ b/Private/Resolve-UnifiSite.ps1 @@ -0,0 +1,6 @@ +function Resolve-UnifiSite { + param([string]$Site) + if ($Site) { return $Site } + if ($script:UnifiConfig.DefaultSite) { return $script:UnifiConfig.DefaultSite } + return 'default' +} diff --git a/Private/UnifiConfig.ps1 b/Private/UnifiConfig.ps1 new file mode 100644 index 0000000..49f370c --- /dev/null +++ b/Private/UnifiConfig.ps1 @@ -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 +} diff --git a/Public/Connect-UnifiController.ps1 b/Public/Connect-UnifiController.ps1 new file mode 100644 index 0000000..1d118de --- /dev/null +++ b/Public/Connect-UnifiController.ps1 @@ -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 +} diff --git a/Public/Disconnect-UnifiController.ps1 b/Public/Disconnect-UnifiController.ps1 new file mode 100644 index 0000000..e8245de --- /dev/null +++ b/Public/Disconnect-UnifiController.ps1 @@ -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 +} diff --git a/Public/Get-UnifiClient.ps1 b/Public/Get-UnifiClient.ps1 new file mode 100644 index 0000000..e21fda7 --- /dev/null +++ b/Public/Get-UnifiClient.ps1 @@ -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 { '-' } } } +} diff --git a/Public/Get-UnifiConnectionStatus.ps1 b/Public/Get-UnifiConnectionStatus.ps1 new file mode 100644 index 0000000..9b1ddf0 --- /dev/null +++ b/Public/Get-UnifiConnectionStatus.ps1 @@ -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 or unifi-cli connect " + 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 )" -ForegroundColor DarkGray + + if ($script:UnifiConfig.SkipCertificateCheck) { + Write-Host "TLS: " -NoNewline -ForegroundColor White + Write-Host "Certificate check disabled" -ForegroundColor Yellow + } +} diff --git a/Public/Get-UnifiDevice.ps1 b/Public/Get-UnifiDevice.ps1 new file mode 100644 index 0000000..9879d15 --- /dev/null +++ b/Public/Get-UnifiDevice.ps1 @@ -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 } } +} diff --git a/Public/Get-UnifiSite.ps1 b/Public/Get-UnifiSite.ps1 new file mode 100644 index 0000000..2bae8fd --- /dev/null +++ b/Public/Get-UnifiSite.ps1 @@ -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 } } +} diff --git a/Public/Get-UnifiWlan.ps1 b/Public/Get-UnifiWlan.ps1 new file mode 100644 index 0000000..e4eeb9c --- /dev/null +++ b/Public/Get-UnifiWlan.ps1 @@ -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 +} diff --git a/Public/Invoke-UnifiCli.ps1 b/Public/Invoke-UnifiCli.ps1 new file mode 100644 index 0000000..51ee7ca --- /dev/null +++ b/Public/Invoke-UnifiCli.ps1 @@ -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 [--port ] [--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 (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 " } + } + } + + '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 [--site ]" + return + } + Set-UnifiWlanPassword @p + } + default { Write-Warning "Unknown: unifi-cli set $noun. Try: unifi-cli set wlan-password --ssid " } + } + } + + { $_ -in 'list', 'get', 'show' } { + $target = if ($Arguments.Count -gt 0) { $Arguments[0].ToLower() } else { '' } + + # Parse shared flags: --site , --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 [--port ] [--insecure] [--save] [--totp ]" + Write-Host " unifi-cli disconnect" + Write-Host " unifi-cli status" + Write-Host " unifi-cli use site [--save]" + Write-Host " unifi-cli list sites" + Write-Host " unifi-cli list devices [--site ]" + Write-Host " unifi-cli list clients [--site ] [--active]" + Write-Host " unifi-cli list wlans [--site ] [--ssid ] [--show-password]" + Write-Host " unifi-cli set wlan-password --ssid [--site ]" + Write-Host "" + Write-Host "Native PS functions: Connect-UnifiController, Get-UnifiSite, etc." -ForegroundColor DarkGray + Write-Host "" + } + } +} diff --git a/Public/Set-UnifiDefaultSite.ps1 b/Public/Set-UnifiDefaultSite.ps1 new file mode 100644 index 0000000..cc8c5d3 --- /dev/null +++ b/Public/Set-UnifiDefaultSite.ps1 @@ -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 +} diff --git a/Public/Set-UnifiWlanPassword.ps1 b/Public/Set-UnifiWlanPassword.ps1 new file mode 100644 index 0000000..e567933 --- /dev/null +++ b/Public/Set-UnifiWlanPassword.ps1 @@ -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 + } +} diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..eda9086 --- /dev/null +++ b/TESTING.md @@ -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\\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 +``` + +--- + +### 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 +``` + +**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 | diff --git a/UnifiCLI.psd1 b/UnifiCLI.psd1 new file mode 100644 index 0000000..56575ce --- /dev/null +++ b/UnifiCLI.psd1 @@ -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' + } + } +} diff --git a/UnifiCLI.psm1 b/UnifiCLI.psm1 new file mode 100644 index 0000000..a34bd57 --- /dev/null +++ b/UnifiCLI.psm1 @@ -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