When doploying a new environment (or when running an environment for years already ;)) you always need to change the password from the default or from something else to something else 😉 (When they expire). I’ve created a nice PowerShell script that will do this automatically for you or you can run the script interactive (just dont use any parameters). Here are some examples:
- Just see what’s out there, change nothing. -PCPassword is the password YOU use to log in to Prism Central as -PCUser omit it (as here) and the script prompts for it instead:
./change_passwords.ps1 -PCHost <PC_IP> -PCUser admin -DryRun - All accounts already share ONE current password → generate ONE new random password for every account. -ConfirmClusterLockdown confirms Cluster Lockdown + a working SSH key are in place, so the script doesn’t stop to ask about it. -PCPassword is supplied here too so the whole thing runs with zero prompts:
./change_passwords.ps1 -PCHost<PC_IP>-PCUser admin -PCPassword 'OldShared123!'-CurrentPassword 'OldShared123!' -GeneratePassword -ConfirmClusterLockdown - Accounts have a DIFFERENT current password per type (e.g. PC’s admin password != CVM’s admin password these are NOT the same account), but you still want ONE new shared password for everything. -PCPassword is the login for -PCUser on PC itself, so it matches -CurrentPasswordPcAdmin here (both describe PC admin’s current password) NOT -CurrentPasswordCvmAdmin:
./change_passwords.ps1 -PCHost<PC_IP>-PCUser admin -PCPassword 'OldPcAdminPw!' -CurrentPasswordPcAdmin 'OldPcAdminPw!' -CurrentPasswordCvmAdmin 'OldCvmAdminPw!' -CurrentPasswordPcNutanix 'OldPcNutanixPw!' -CurrentPasswordCvmNutanix 'OldCvmNutanixPw!' -CurrentPasswordAhvRoot 'OldAhvRootPw!' -GeneratePassword -ConfirmClusterLockdown - Everything is different, and you also want a DIFFERENT new password per account type (no auto-generation, you choose every value):
./change_passwords.ps1 -PCHost<PC_IP>-PCUser admin -PCPassword 'OldPcAdminPw!' -CurrentPasswordPcAdmin 'OldPcAdminPw!' -CurrentPasswordCvmAdmin 'OldCvmAdminPw!' -CurrentPasswordAhvRoot 'OldAhvRootPw!' -NewPasswordPcAdmin 'NewPcAdminPw1!' -NewPasswordCvmAdmin 'NewCvmAdminPw1!' -NewPasswordAhvRoot 'NewAhvRootPw1!' -ConfirmClusterLockdown - Fully interactive: no passwords on the command line at all the script prompts for -PCPassword itself and the Cluster Lockdown confirmation (and offers to auto-generate a password whenever you leave a new-password prompt empty):
./change_passwords.ps1 -PCHost<PC_IP>-PCUser admin
NOTE: This script is provided “as is”, without warranty of any kind. It changes local system account passwords (CVM, AHV, and Prism Central) directly via the Prism Central API this is inherently risky: a typo, a lost generated password, an unexpected API response, or a misunderstanding of your own environment can leave you unable to log in to your cluster.
Use this script entirely at your own risk. I’m not responsible for any damage, data loss, downtime, or lockouts resulting from its use including, but not limited to, being locked out of Prism Central, CVMs, or AHV hosts.
Before running this against a production environment:
- Test it first on a non-production/lab cluster.
- Make sure Nutanix Cluster Lockdown is enabled with a working SSH key configured, so you always have a password-independent way into your CVMs/hosts if something goes wrong (the script itself will ask you to confirm this before making any real change).
- Keep a copy of every password the script sets including auto-generated ones, which are only ever shown once in the console output.
If you’re not comfortable with these risks, don’t run this script against systems you can’t afford to lose access to.
#Requires -Version 7.0
<#
.SYNOPSIS
Changes local system passwords (PCVM, CVM, AHV) across all clusters
registered with a Nutanix Prism Central, using the official
Prism Central v4 REST API (resource: clustermgmt.v4.config.SystemUserPassword).
See CLAUDE.md for full documentation, the discovery process, and limitations.
This is the PowerShell version of change_passwords.py; both use exactly
the same endpoints.
.EXAMPLE
# 1) Just see what's out there, change nothing.
./change_passwords.ps1 -PCHost 10.0.11.32 -PCUser admin -DryRun
.EXAMPLE
# 2) All accounts already share ONE current password -> generate ONE
# new random password for every account. -ConfirmClusterLockdown
# confirms Cluster Lockdown + a working SSH key are in place, so the
# script doesn't stop to ask about it.
./change_passwords.ps1 -PCHost 10.0.11.32 -PCUser admin `
-CurrentPassword 'OldShared123!' `
-GeneratePassword `
-ConfirmClusterLockdown
.EXAMPLE
# 3) Accounts have a DIFFERENT current password per type (e.g. PC's
# admin password != CVM's admin password - these are NOT the same
# account), but you still want ONE new shared password for
# everything.
./change_passwords.ps1 -PCHost 10.0.11.32 -PCUser admin `
-CurrentPasswordPcAdmin 'OldPcAdminPw!' `
-CurrentPasswordCvmAdmin 'OldCvmAdminPw!' `
-CurrentPasswordPcNutanix 'OldPcNutanixPw!' `
-CurrentPasswordCvmNutanix 'OldCvmNutanixPw!' `
-CurrentPasswordAhvRoot 'OldAhvRootPw!' `
-GeneratePassword `
-ConfirmClusterLockdown
.EXAMPLE
# 4) Everything is different, and you also want a DIFFERENT new
# password per account type (no auto-generation, you choose every
# value).
./change_passwords.ps1 -PCHost 10.0.11.32 -PCUser admin `
-CurrentPasswordPcAdmin 'OldPcAdminPw!' `
-CurrentPasswordCvmAdmin 'OldCvmAdminPw!' `
-CurrentPasswordAhvRoot 'OldAhvRootPw!' `
-NewPasswordPcAdmin 'NewPcAdminPw1!' `
-NewPasswordCvmAdmin 'NewCvmAdminPw1!' `
-NewPasswordAhvRoot 'NewAhvRootPw1!' `
-ConfirmClusterLockdown
.EXAMPLE
# 5) Fully interactive: no passwords on the command line at all - the
# script prompts for everything it needs, including the Cluster
# Lockdown confirmation (and offers to auto-generate a password
# whenever you leave a new-password prompt empty).
./change_passwords.ps1 -PCHost 10.0.11.32 -PCUser admin
.NOTES
-CurrentPasswordAdmin / -NewPasswordAdmin etc. apply to 'admin' accounts
on EVERY system type at once (PC and CVM). Only use them when you know
PC and CVM genuinely share the same password - use the more specific
-CurrentPasswordPcAdmin / -CurrentPasswordCvmAdmin (and their AHV/
nutanix equivalents) whenever they might differ, which is common.
-ConfirmClusterLockdown confirms Nutanix Cluster Lockdown is enabled
with a working SSH key, so there's always a password-independent way
into the CVMs/hosts if a change here goes wrong. Required before any
real (non-dry-run, non-interactive) change; omit it and the script
asks for confirmation interactively instead.
Version : 1.1
Date : 14 September 2026
Created by : Jeroen Tielen - Tielen Consultancy B.V.
Email : tielenjeroen@gmail.com
Changelog:
1.1 - 14 September 2026
- Fixed: changing the PC login account's own password could leave the
rest of the same run using a now-stale credential (task polling,
further accounts), causing spurious 401s and, in practice, tripping
the cluster's account-lockout policy. The session credential is now
switched over as soon as the change is confirmed.
- Added: -ConfirmClusterLockdown safety gate. Before any real
(non-dry-run) change, the user must confirm that Nutanix Cluster
Lockdown is enabled with a working SSH key, so there's always a
password-independent way into the CVMs/hosts if something goes
wrong. Asked interactively if the switch isn't given.
1.0 - 14 September 2026
- Initial release: discover and change PCVM/CVM/AHV local system
account passwords across every cluster registered to a Prism
Central, via the official Prism Central v4 clustermgmt REST API
(no SSH/ncli).
- Added: -GeneratePassword and auto-generation whenever a
new-password prompt is left empty.
- Added: -SamePasswordForAll to skip the "same password for
everyone?" question.
- Added: per (system type, username) password overrides
(-CurrentPasswordPcAdmin, -CurrentPasswordCvmAdmin, etc.), since PC
and CVM 'admin'/'nutanix' are different accounts and can have
different passwords.
- Added: group-wise pre-check before bulk changes (try one account
per group first) to avoid repeating a wrong password across every
node/cluster and risking an account lockout.
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
# Not [Parameter(Mandatory)] so that -Version works without also having
# to supply -PCHost; presence is checked manually further below instead.
[string] $PCHost,
[string] $PCUser = 'admin',
[string] $PCPassword,
[string] $CurrentPassword,
[string] $CurrentPasswordAdmin,
[string] $CurrentPasswordNutanix,
[string] $CurrentPasswordRoot,
[string] $NewPassword,
[string] $NewPasswordAdmin,
[string] $NewPasswordNutanix,
[string] $NewPasswordRoot,
# Per (system type, username) overrides. PC and CVM (AOS) both have
# 'admin'/'nutanix' accounts, and their passwords are NOT necessarily
# the same - we learned this the hard way in this very project. These
# take priority over the per-username params above, which take priority
# over -CurrentPassword/-NewPassword.
[string] $CurrentPasswordPcAdmin,
[string] $CurrentPasswordPcNutanix,
[string] $CurrentPasswordCvmAdmin,
[string] $CurrentPasswordCvmNutanix,
[string] $CurrentPasswordAhvRoot,
[string] $CurrentPasswordAhvAdmin,
[string] $CurrentPasswordAhvNutanix,
[string] $NewPasswordPcAdmin,
[string] $NewPasswordPcNutanix,
[string] $NewPasswordCvmAdmin,
[string] $NewPasswordCvmNutanix,
[string] $NewPasswordAhvRoot,
[string] $NewPasswordAhvAdmin,
[string] $NewPasswordAhvNutanix,
[string] $Types = 'PC,AOS,AHV',
[string] $Usernames = 'admin,nutanix,root',
[switch] $DryRun,
[switch] $Yes,
[switch] $VerifySsl,
[switch] $SamePasswordForAll,
[switch] $GeneratePassword,
[switch] $ConfirmClusterLockdown,
[switch] $Version
)
$ScriptVersion = '1.1'
$ErrorActionPreference = 'Stop'
if ($Version) {
Write-Host "change_passwords.ps1 v$ScriptVersion"
exit 0
}
if (-not $PCHost) {
throw "-PCHost is required (unless using -Version)."
}
Write-Host "change_passwords.ps1 v$ScriptVersion"
$SystemTypeFriendly = @{ PC = 'Prism Central (PC)'; AOS = 'CVM (AOS)'; AHV = 'AHV' }
# Combo-override lookup tables, keyed by "SystemType|username".
$ComboCurrent = [ordered]@{
'PC|admin' = $CurrentPasswordPcAdmin
'PC|nutanix' = $CurrentPasswordPcNutanix
'AOS|admin' = $CurrentPasswordCvmAdmin
'AOS|nutanix' = $CurrentPasswordCvmNutanix
'AHV|root' = $CurrentPasswordAhvRoot
'AHV|admin' = $CurrentPasswordAhvAdmin
'AHV|nutanix' = $CurrentPasswordAhvNutanix
}
$ComboNew = [ordered]@{
'PC|admin' = $NewPasswordPcAdmin
'PC|nutanix' = $NewPasswordPcNutanix
'AOS|admin' = $NewPasswordCvmAdmin
'AOS|nutanix' = $NewPasswordCvmNutanix
'AHV|root' = $NewPasswordAhvRoot
'AHV|admin' = $NewPasswordAhvAdmin
'AHV|nutanix' = $NewPasswordAhvNutanix
}
function Get-ComboLabel {
param([string] $SystemType, [string] $Username)
return "'$Username' accounts on $($SystemTypeFriendly[$SystemType])"
}
function Set-ComboCurrent {
param([string] $SystemType, [string] $Username, [string] $Value)
$script:ComboCurrent["$SystemType|$Username"] = $Value
}
function Set-ComboNew {
param([string] $SystemType, [string] $Username, [string] $Value)
$script:ComboNew["$SystemType|$Username"] = $Value
}
function Get-AnyComboValueSet {
param([hashtable] $Combo)
foreach ($v in $Combo.Values) { if ($v) { return $true } }
return $false
}
# clustermgmt/prism resource-group versions to try, newest first.
# The exact minor version that exposes "system-user-passwords" differs per
# PC release; the script tries them in order until one works.
$ClustermgmtVersions = @('v4.2', 'v4.1', 'v4.0.b2', 'v4.0.b1', 'v4.0')
$PrismTaskVersions = @('v4.2', 'v4.1', 'v4.0.b2', 'v4.0.b1', 'v4.0')
$SystemTypes = @('PC', 'AOS', 'AHV')
function Read-SecureText {
param([string] $Prompt)
$sec = Read-Host -Prompt $Prompt -AsSecureString
return [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
[System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
}
# Character set used for generated passwords. Deliberately excludes '@'
# (asked for explicitly) and shell-unfriendly characters (quotes, backslash,
# backtick) so a generated password can always be safely re-used on a
# command line.
$GeneratedSpecialChars = '!#$%^&*-_=+?~'
$GeneratedPasswordLength = 20
function Get-CharClass {
param([char] $Ch)
if ($Ch -cmatch '[A-Z]') { return 'upper' }
if ($Ch -cmatch '[a-z]') { return 'lower' }
if ($Ch -cmatch '[0-9]') { return 'digit' }
return 'special'
}
function Get-SecureRandomChar {
param([char[]] $Pool)
$bytes = [byte[]]::new(4)
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
$val = [System.BitConverter]::ToUInt32($bytes, 0)
return $Pool[[int]($val % $Pool.Count)]
}
function New-GeneratedPassword {
<#
Generates a random password that satisfies Nutanix's complexity rules:
- at least 15 characters (default length here is well above that)
- contains at least one of each character class
- no more than 2 identical characters in a row
- no more than 4 characters in a row from the same character class
- does not contain '@'
- does not start with 'nutanix' or 'password'
("differs by 8+ characters from the old password" and "not one of the
last 24 passwords" can't be verified here without knowing the account's
password history; a fully random password of this length satisfies
those in practice.)
#>
param([int] $Length = $GeneratedPasswordLength)
# NB: build the pool from literal strings, not the 'A'..'Z' range operator.
# PowerShell's range operator treats purely-numeric-looking strings like
# '0'..'9' as an INTEGER range (0-9, i.e. control characters 0x00-0x09
# once cast to [char]), not as the digit characters '0'-'9' - which would
# silently make it impossible to ever generate a digit.
$pool = ('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + $GeneratedSpecialChars).ToCharArray()
while ($true) {
$chars = [System.Collections.Generic.List[char]]::new()
$lastClass = $null
$classRun = 0
$lastChar = $null
$identicalRun = 0
for ($i = 0; $i -lt $Length; $i++) {
for ($attempt = 0; $attempt -lt 200; $attempt++) {
$ch = Get-SecureRandomChar -Pool $pool
$cls = Get-CharClass $ch
$breaksClassRun = ($cls -eq $lastClass -and $classRun -ge 4)
$breaksIdenticalRun = ($ch -eq $lastChar -and $identicalRun -ge 2)
if (-not $breaksClassRun -and -not $breaksIdenticalRun) { break }
}
$chars.Add($ch)
if ($cls -eq $lastClass) { $classRun++ } else { $classRun = 1; $lastClass = $cls }
if ($ch -eq $lastChar) { $identicalRun++ } else { $identicalRun = 1; $lastChar = $ch }
}
$pw = -join $chars
if ($pw.Contains('@')) { continue }
if ($pw.ToLower().StartsWith('nutanix') -or $pw.ToLower().StartsWith('password')) { continue }
$classesPresent = @($chars | ForEach-Object { Get-CharClass $_ } | Sort-Object -Unique)
if ($classesPresent.Count -lt 4) { continue }
return $pw
}
}
if (-not $PCPassword) {
if ($env:NUTANIX_PC_PASSWORD) {
$PCPassword = $env:NUTANIX_PC_PASSWORD
}
else {
$PCPassword = Read-SecureText -Prompt "Password for $PCUser@$PCHost"
}
}
$baseUrl = "https://${PCHost}:9440"
function Set-AuthHeader {
param([string] $User, [string] $Pass)
$pair = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("${User}:${Pass}"))
$script:authHeaders = @{ Authorization = "Basic $pair" }
}
Set-AuthHeader -User $PCUser -Pass $PCPassword
$restArgs = @{}
if (-not $VerifySsl) {
$restArgs['SkipCertificateCheck'] = $true
}
function Invoke-PcApi {
param(
[string] $Method = 'GET',
[Parameter(Mandatory = $true)] [string] $Url,
[hashtable] $ExtraHeaders,
[string] $Body
)
$headers = $authHeaders.Clone()
if ($ExtraHeaders) { foreach ($k in $ExtraHeaders.Keys) { $headers[$k] = $ExtraHeaders[$k] } }
$callArgs = @{
Method = $Method
Uri = $Url
Headers = $headers
}
if ($Body) {
$callArgs['Body'] = $Body
$callArgs['ContentType'] = 'application/json'
}
foreach ($k in $restArgs.Keys) { $callArgs[$k] = $restArgs[$k] }
return Invoke-RestMethod @callArgs
}
function Find-WorkingVersion {
param([string[]] $Versions, [string] $PathFormat)
foreach ($v in $Versions) {
$url = $baseUrl + ($PathFormat -f $v)
try {
Invoke-PcApi -Url $url | Out-Null
return $v
}
catch {
$status = $null
if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode }
if ($status -ne 404) { return $v } # exists, different error (e.g. auth) -> version is correct
}
}
throw "No working API version found among: $($Versions -join ', '). Check the PC version and adjust the version list at the top of this script."
}
$script:ClustermgmtVersion = $null
function Get-ClustermgmtVersion {
if (-not $script:ClustermgmtVersion) {
$script:ClustermgmtVersion = Find-WorkingVersion -Versions $ClustermgmtVersions `
-PathFormat '/api/clustermgmt/{0}/config/system-user-passwords'
}
return $script:ClustermgmtVersion
}
$script:PrismVersion = $null
function Get-PrismVersion {
if (-not $script:PrismVersion) {
$script:PrismVersion = Find-WorkingVersion -Versions $PrismTaskVersions `
-PathFormat '/api/prism/{0}/config/tasks'
}
return $script:PrismVersion
}
function Get-SystemAccounts {
param([string] $SystemType)
$version = Get-ClustermgmtVersion
$page = 0
$limit = 50
$accounts = @()
while ($true) {
$filter = "(systemType eq Clustermgmt.Config.SystemType'$SystemType')"
$url = "$baseUrl/api/clustermgmt/$version/config/system-user-passwords" +
"?`$page=$page&`$limit=$limit&`$filter=$([Uri]::EscapeDataString($filter))"
$resp = Invoke-PcApi -Url $url
foreach ($e in $resp.data) {
$hostIp = $null
if ($e.hostIp) { $hostIp = $e.hostIp.value }
$accounts += [PSCustomObject]@{
ExtId = $e.extId
SystemType = $e.systemType
Username = $e.username
ClusterExtId = $e.clusterExtId
HostIp = $hostIp
Status = $e.status
}
}
$total = $resp.metadata.totalAvailableResults
if ((($page + 1) * $limit) -ge $total -or $resp.data.Count -eq 0) { break }
$page++
}
return $accounts
}
function Set-SystemAccountPassword {
param($Account, [string] $CurrentPw, [string] $NewPw)
$version = Get-ClustermgmtVersion
$url = "$baseUrl/api/clustermgmt/$version/config/system-user-passwords/$($Account.ExtId)/`$actions/change-password"
$body = @{ currentPassword = $CurrentPw; newPassword = $NewPw } | ConvertTo-Json -Compress
$resp = Invoke-PcApi -Method 'POST' -Url $url -Body $body -ExtraHeaders @{ 'NTNX-Request-Id' = [Guid]::NewGuid().ToString() }
return $resp.data.extId
}
function Wait-PcTask {
param([string] $TaskExtId, [int] $TimeoutSec = 120)
$version = Get-PrismVersion
$url = "$baseUrl/api/prism/$version/config/tasks/$TaskExtId"
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
$resp = Invoke-PcApi -Url $url
$pct = $resp.data.progressPercentage
$status = $resp.data.status
if (-not $status) { $status = if ($pct -eq 100) { 'COMPLETED' } else { 'RUNNING' } }
if ($pct -eq 100 -or $status -in @('SUCCEEDED', 'FAILED', 'COMPLETED', 'CANCELED')) {
return $resp.data
}
Start-Sleep -Seconds 2
}
throw "Task $TaskExtId did not finish within $TimeoutSec s"
}
function Get-CurrentPasswordFor {
param($Account)
$key = "$($Account.SystemType)|$($Account.Username)"
if ($ComboCurrent.Contains($key) -and $ComboCurrent[$key]) { return $ComboCurrent[$key] }
switch ($Account.Username) {
'admin' { if ($CurrentPasswordAdmin) { return $CurrentPasswordAdmin } }
'nutanix' { if ($CurrentPasswordNutanix) { return $CurrentPasswordNutanix } }
'root' { if ($CurrentPasswordRoot) { return $CurrentPasswordRoot } }
}
return $CurrentPassword
}
function Get-NewPasswordFor {
param($Account)
$key = "$($Account.SystemType)|$($Account.Username)"
if ($ComboNew.Contains($key) -and $ComboNew[$key]) { return $ComboNew[$key] }
switch ($Account.Username) {
'admin' { if ($NewPasswordAdmin) { return $NewPasswordAdmin } }
'nutanix' { if ($NewPasswordNutanix) { return $NewPasswordNutanix } }
'root' { if ($NewPasswordRoot) { return $NewPasswordRoot } }
}
return $NewPassword
}
function Format-Account {
param($Account)
$loc = if ($Account.HostIp) { $Account.HostIp } elseif ($Account.ClusterExtId) { $Account.ClusterExtId } else { '-' }
return "[{0,-4}] {1,-8} @ {2} (status={3})" -f $Account.SystemType, $Account.Username, $loc, $Account.Status
}
# ---- Main ----
$typesList = $Types.Split(',') | ForEach-Object { $_.Trim().ToUpper() } | Where-Object { $_ }
$usernameSet = @{}
$Usernames.Split(',') | ForEach-Object { $u = $_.Trim(); if ($u) { $usernameSet[$u] = $true } }
Write-Host "Discovering accounts via clustermgmt $(Get-ClustermgmtVersion) API on $PCHost ..."
$allAccounts = @()
foreach ($t in $typesList) {
if ($SystemTypes -notcontains $t) {
Write-Warning "Unknown type '$t', skipped (valid: $($SystemTypes -join ', '))"
continue
}
$allAccounts += Get-SystemAccounts -SystemType $t
}
$targets = $allAccounts | Where-Object { $usernameSet.ContainsKey($_.Username) }
if (-not $targets -or $targets.Count -eq 0) {
Write-Host "No accounts found matching the requested types/usernames."
exit 1
}
Write-Host ""
Write-Host "$($targets.Count) account(s) found to process:"
foreach ($a in $targets) { Write-Host " $(Format-Account $a)" }
if ($DryRun) {
Write-Host ""
Write-Host "-DryRun: nothing was changed."
exit 0
}
if (-not $ConfirmClusterLockdown) {
$answer = Read-Host (
"`nBefore making any changes: if something goes wrong here (a lost " +
"generated password, a bug, ...), you could end up unable to log in " +
"with a password at all. Nutanix's Cluster Lockdown feature " +
"(Settings > Cluster Lockdown) disables SSH password login entirely " +
"and only allows your registered SSH public key - so with lockdown " +
"enabled and a working key, you always have a way in regardless of " +
"what happens to these passwords.`n" +
"Do you confirm Cluster Lockdown is enabled and your SSH key works " +
"on this cluster? [y/N]"
)
if ($answer.Trim().ToLower() -notin @('y', 'yes')) {
Write-Host (
"Refusing to proceed without this safety confirmation. Enable " +
"Cluster Lockdown and add your SSH public key first (Prism " +
"Settings > Cluster Lockdown), or pass -ConfirmClusterLockdown " +
"if you accept the risk without it."
)
exit 1
}
}
$anyNewGiven = [bool]($NewPassword -or $NewPasswordAdmin -or $NewPasswordNutanix -or $NewPasswordRoot -or (Get-AnyComboValueSet $ComboNew))
if ($anyNewGiven) {
if ($SamePasswordForAll -and ($NewPasswordAdmin -or $NewPasswordNutanix -or $NewPasswordRoot -or (Get-AnyComboValueSet $ComboNew))) {
Write-Host ""
Write-Host "-SamePasswordForAll given: ignoring -NewPasswordAdmin/-Nutanix/-Root and -NewPassword<Type><User> overrides, using -NewPassword for every account."
$NewPasswordAdmin = $NewPasswordNutanix = $NewPasswordRoot = $null
foreach ($key in @($ComboNew.Keys)) { $ComboNew[$key] = $null }
}
}
elseif ($GeneratePassword) {
$NewPassword = New-GeneratedPassword
Write-Host "`nGenerated new password for ALL $($targets.Count) account(s): $NewPassword"
}
elseif ($SamePasswordForAll) {
$NewPassword = Read-SecureText -Prompt "`nNew password to set for ALL $($targets.Count) accounts listed above (Ctrl+C to abort, leave empty to auto-generate one)"
if (-not $NewPassword) {
$NewPassword = New-GeneratedPassword
Write-Host "Generated password: $NewPassword"
}
}
else {
$candidate = Read-SecureText -Prompt "`nNew password (Ctrl+C to abort, leave empty to auto-generate one)"
if (-not $candidate) {
$candidate = New-GeneratedPassword
Write-Host "Generated password: $candidate"
}
$answer = Read-Host "Use this same password for ALL $($targets.Count) account(s) listed above? [Y/n]"
if ($answer.Trim() -eq '' -or $answer.Trim().ToLower() -in @('y', 'yes')) {
$NewPassword = $candidate
}
else {
# Prompt per (system type, username) combo, NOT just per username:
# e.g. PC 'admin' and CVM 'admin' are different accounts and can
# (and often do) have different passwords.
$presentCombos = $targets | ForEach-Object { "$($_.SystemType)|$($_.Username)" } | Sort-Object -Unique
foreach ($comboKey in $presentCombos) {
$parts = $comboKey -split '\|', 2
$st = $parts[0]; $un = $parts[1]
$pw = Read-SecureText -Prompt "New password for $(Get-ComboLabel $st $un) (leave empty to auto-generate)"
if (-not $pw) {
$pw = New-GeneratedPassword
Write-Host "Generated password for $(Get-ComboLabel $st $un): $pw"
}
Set-ComboNew -SystemType $st -Username $un -Value $pw
}
}
}
# Safety net: if any target still doesn't resolve to a new password (e.g.
# only -NewPasswordAdmin was given but 'nutanix'/'root' accounts are also
# targeted), auto-generate one per missing (system type, username) combo
# rather than silently sending an empty password.
$missingNew = $targets | Where-Object { -not (Get-NewPasswordFor $_) }
if ($missingNew) {
$missingCombos = $missingNew | ForEach-Object { "$($_.SystemType)|$($_.Username)" } | Sort-Object -Unique
foreach ($comboKey in $missingCombos) {
$parts = $comboKey -split '\|', 2
$st = $parts[0]; $un = $parts[1]
$pw = New-GeneratedPassword
Write-Host "Generated new password for $(Get-ComboLabel $st $un): $pw"
Set-ComboNew -SystemType $st -Username $un -Value $pw
}
}
$missingCurrent = $targets | Where-Object { -not (Get-CurrentPasswordFor $_) }
if ($missingCurrent) {
Write-Host ""
Write-Host "No current password provided yet for:"
foreach ($a in $missingCurrent) { Write-Host " $(Format-Account $a)" }
Write-Host ""
# Same rationale as above: ask per (system type, username) combo, so
# e.g. PC 'admin' and CVM 'admin' each get their own prompt instead of
# silently assuming they share one password.
$missingCurrentCombos = $missingCurrent | ForEach-Object { "$($_.SystemType)|$($_.Username)" } | Sort-Object -Unique
foreach ($comboKey in $missingCurrentCombos) {
$parts = $comboKey -split '\|', 2
$st = $parts[0]; $un = $parts[1]
$pw = Read-SecureText -Prompt "Current password for $(Get-ComboLabel $st $un)"
Set-ComboCurrent -SystemType $st -Username $un -Value $pw
}
$stillMissing = $targets | Where-Object { -not (Get-CurrentPasswordFor $_) }
if ($stillMissing) {
Write-Host ""
Write-Warning "Still missing a current password for:"
foreach ($a in $stillMissing) { Write-Host " $(Format-Account $a)" }
exit 1
}
}
if (-not $Yes) {
$answer = Read-Host "`nPasswords for $($targets.Count) account(s) are about to be changed. Continue? [y/N]"
if ($answer -notmatch '^(y|yes)$') {
Write-Host "Cancelled."
exit 1
}
}
function Invoke-ChangeAttempt {
param($Account, [ref] $Failures)
$current = Get-CurrentPasswordFor $Account
$new = Get-NewPasswordFor $Account
# If we're changing the PC login account we're currently authenticating
# with, the new password can take effect server-side almost immediately
# - possibly before the async task shows as complete. If that happens,
# polling with the OLD password gets a 401; retry once with the NEW
# password in that specific case, and if that's what actually fixed it,
# keep using the new password for every request from here on (including
# any further accounts processed in this run) so we don't keep
# hammering the API with a stale credential, which risks tripping an
# account lockout.
$isLoginAccount = ($Account.SystemType -eq 'PC' -and $Account.Username -eq $PCUser)
try {
$taskId = Set-SystemAccountPassword -Account $Account -CurrentPw $current -NewPw $new
try {
$result = Wait-PcTask -TaskExtId $taskId
}
catch {
$is401 = $false
if ($isLoginAccount -and $_.Exception.Response) {
$is401 = ([int]$_.Exception.Response.StatusCode -eq 401)
}
if (-not $is401) { throw }
Set-AuthHeader -User $PCUser -Pass $new
try {
$result = Wait-PcTask -TaskExtId $taskId
}
catch {
# Retry with the new password didn't help either - restore
# the known-good old credential rather than leaving the
# session on an unproven guess.
Set-AuthHeader -User $PCUser -Pass $current
throw
}
}
$status = $result.status
if (-not $status) { $status = if ($result.progressPercentage -eq 100) { 'COMPLETED' } else { 'UNKNOWN' } }
$ok = $status -notin @('FAILED', 'CANCELED')
$label = if ($ok) { 'OK ' } else { 'FAIL' }
Write-Host " $label $(Format-Account $Account) -> task status: $status"
if ($isLoginAccount -and $ok) {
# Confirmed successful: make sure every later request in this
# run (other accounts) uses the now-current password.
Set-AuthHeader -User $PCUser -Pass $new
}
if (-not $ok) { $Failures.Value += $Account }
return $ok
}
catch {
Write-Host " FAIL $(Format-Account $Account) -> $($_.Exception.Message)"
$Failures.Value += $Account
return $false
}
}
Write-Host ""
$failures = @()
# Group by SystemType+Username: accounts in the same group virtually always
# share the same current password (e.g. every AHV 'root'). Try the first
# account in a group as a one-time check; if that fails, skip the rest of
# the group instead of repeating the same likely-wrong password attempt on
# every node/cluster, which risks triggering an account lockout.
$groups = [ordered]@{}
foreach ($a in $targets) {
$key = "$($a.SystemType)|$($a.Username)"
if (-not $groups.Contains($key)) { $groups[$key] = @() }
$groups[$key] += $a
}
foreach ($key in $groups.Keys) {
$accountsInGroup = $groups[$key]
$first = $accountsInGroup[0]
$rest = @($accountsInGroup | Select-Object -Skip 1)
$ok = Invoke-ChangeAttempt -Account $first -Failures ([ref]$failures)
if ($ok) {
foreach ($a in $rest) {
Invoke-ChangeAttempt -Account $a -Failures ([ref]$failures) | Out-Null
}
}
elseif ($rest.Count -gt 0) {
$parts = $key -split '\|', 2
Write-Host " -> current password for '$($parts[1])' ($($parts[0])) looks incorrect (or the change failed for another reason); skipping the other $($rest.Count) account(s) in this group to avoid repeated failed attempts / a possible lockout."
$failures += $rest
}
}
Write-Host ""
if ($failures.Count -gt 0) {
Write-Warning "$($failures.Count) of $($targets.Count) account(s) NOT successfully changed:"
foreach ($a in $failures) { Write-Host " $(Format-Account $a)" }
exit 1
}
Write-Host "All $($targets.Count) account(s) successfully changed."
exit 0Discover more from Jeroen Tielen
Subscribe to get the latest posts sent to your email.