PowerShell script to API-Change all Nutanix password

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:

  1. 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
  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. -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
  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. -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
  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 <PC_IP> -PCUser admin -PCPassword 'OldPcAdminPw!' -CurrentPasswordPcAdmin 'OldPcAdminPw!' -CurrentPasswordCvmAdmin 'OldCvmAdminPw!' -CurrentPasswordAhvRoot 'OldAhvRootPw!' -NewPasswordPcAdmin 'NewPcAdminPw1!' -NewPasswordCvmAdmin 'NewCvmAdminPw1!' -NewPasswordAhvRoot 'NewAhvRootPw1!' -ConfirmClusterLockdown
  5. 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 0


Discover more from Jeroen Tielen

Subscribe to get the latest posts sent to your email.

Posts created 164

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Related Posts

Begin typing your search term above and press enter to search. Press ESC to cancel.

Back To Top