NTNX NGT Startup Installer

January 12, 2022

by Ed McAndrew

Intended Audience Level: Intermediate

Code Sample Type: Complete Script

Nutanix Technologies: Prism Element

Minimum Product Version: N/A

Script/Code Language: PowerShell

REST API Sample? Yes

REST API Version: v3

This script is an alternative method to using Prism Central for bulk deployment of Nutanix Guest Tools within an Active Directory environment. For this to work properly, the VM computer name that is executing this script must be identical (including case) to the VM name in Prism Element. You must also include Prism Element credentials. When the script runs, it will use REST to query the Prism Element cluster and mount it’s own NGT ISO, again using REST.

The script should be run from an Active Directory startup GPO so that the SYSTEM account is used as opposed to a logon script which will use the individual logon user credentials.

The script uses an AES encrypted password hash for storing the Prism Element credentials. The variables $my_prism_pass and $my_hashkey can be generated using GenPasswordEncryption.ps1 found elsewhere here on nutanix.dev.

This script will:
1. Attempt to find the computer name in Prism Element and mount it’s own NGT ISO.
2. Install NGT if not previously installed.
3. Upgrade NGT if it was previously installed and the installed version is older than the version located on the NGT ISO.

Note all of the prerequisites in the prerequisites section of the comment block.

This script is provided as an example. You may use it as it is currently written, or re-write it to better suit your needs. For example, if you do not want the script to run each time the VM reboots, you may wish to add some form of flag to only permit to run on select occasions.

Code Sample Details

Note this section may be empty, if additional code sample details are not available.
<#
.notes
##############################################################################
#	 	 Nutanix Guest Tools Active Directory Installer Script
#	 	 Filename			:	  NTNX_NGT_Startup_Installer.ps1
#	 	 Script Version		:	  2.0.14
##############################################################################
.prerequisites
	1. Powershell 5 or above ($psversiontable.psversion.major)
	2. Nutanix AHV cluster; does not work "as-is" with ESXi or Hyper-V
	3. Populate $my_cluster_vip_addresses with ALL of the Nutanix Cluster VIP address that you wish to search for Virtual Machines on.
	4. Create idential local user accounts on each of the clusters defined in step 3 above.
		Note: This "should" work with a single LDAP / Active Directory credentials as well.  So long as the user can authenticate to each cluster. But I have not tested this.
	5. Using the GenPasswordEncryption.ps1 script (also available on http://nutanix.dev), generate an AES encrypted password and hash for the above user account. Copy those to the $my_prism_pass and $my_hashkey variables below.
	6. The Windows Computer Name MUST BE identical to the Nutanix VM Name in Prism.  This is the only way for this script to find the correct VM in Prism.
		I.E.
			TestVM != testVM
			TestVM != TEstVm
			TESTVM != testvm
			TESTvM == TESTvM
	7. This script is designed to be run from an Active Directory startup GPO (using the SYSTEM account on the workstations), or via elevated SCCM jobs.
	8. Disable IE "first-run" dialog via GPO as it prevents the use of the invoke-webrequest method.
.synopsis
    Determine if Nutanix Guest Tools (NGT) is installed.  If not installed, determine mount path for NGT ISO and install from that.  Basic state information written to Application Event log under eventid 1.
.usage
	Run this script from an Active Directory startup script GPO
.disclaimer
    This code is intended as a standalone example.  Subject to licensing restrictions defined on nutanix.dev, this can be downloaded, copied and/or modified in any way you see fit.
    Please be aware that all public code samples provided by Nutanix are unofficial in nature, are provided as examples only, are unsupported and will need to be heavily scrutinized and potentially modified before they can be used in a production environment.  All such code samples are provided on an as-is basis, and Nutanix expressly disclaims all warranties, express or implied.
 
    All code samples are © Nutanix, Inc., and are provided as-is under the MIT license. (https://opensource.org/licenses/MIT)
#>
##############################################################################
# Set Variables Below
##############################################################################
[string]$my_prism_username = "username_here" # Prism username; the username and passwords should be the same on all clusters.
[string]$my_prism_pass = "XCxwl5GlCk2bNh3V4F9HeGp+C2E8HAxc7SZKNM0O7z0=" # Use the AES password hash script for your password.
[array]$my_hashkey = (64,99,33,83,93,24,113,9,61,57,11,98,52,37,101,25,43,110,30,77,107,76,44,115,24,36,67,76,114,95,65,25) # Use the AES password hash script for your hashing key.
[array]$my_cluster_vip_addresses = @("first_cluster_ip_address","second_cluster_ip_address","third_cluster_ip_address") # Define all of your clusters by IPv4 address here!
[int]$my_rest_timeout = 5 # Define your timeout in seconds for REST API calls.
[int]$my_max_iso_retries = 5 # Define your maxim attempts to check that the NGT ISO is mounted.
[string]$my_log_directory = "c:\temp"
[bool]$my_debug = $false # Debug mode $true / $false.
[bool]$my_dry_run = $false # Dry run mode (no installation): $true / $false.
[bool]$my_write_to_event_log = $true # Allow the script to write to the Windows Application Event Log: $true / $false. Writing to the event log will not occur if debugging is enabled.
##############################################################################
#////////////////////////////////////////////////////////////////////////////////////////////////
# CHANGE NOTHING BELOW HERE!
#////////////////////////////////////////////////////////////////////////////////////////////////
##############################################################################
[string]$my_temperract = $erroractionpreference # set error handling preferences
[string]$erroractionpreference = "stop" # set error handling preferences
[int]$ntnx_cnt = 0
[string]$my_logfile = "$($my_log_directory)\ntnx_ngt_startup.log"
if ($my_debug) { if (!(test-path -path $my_log_directory)) { new-item $my_log_directory -type directory -ea silentlycontinue | out-null }; if (test-path $my_logfile) { remove-item $my_logfile -ea silentlycontinue } }
function write-log {
	[cmdletbinding()]
	param(
		[parameter(valuefrompipeline=$true,mandatory=$true)] [validatenotnullorempty()]
		[string] $message,
		[parameter()] [validateset("Error", "Warn", "Info", "Debug")]
		[string] $level = "Info"
	)
	if ($my_write_to_event_log) {
		try {
			$eventid = 1
			$eventlogname = "Application"
			$eventsource = "Nutanix Guest Tools Installer Script"
			if (-not [diagnostics.eventlog]::sourceexists($eventsource)) { [diagnostics.eventlog]::createeventsource($eventsource, $eventlogname) }
			$log = new-object system.diagnostics.eventlog
			$log.set_log($eventlogname)
			$log.set_source($eventsource)
		}
		catch {
			write-log -message "Error Line: $($error[0].invocationinfo.scriptlinenumber)" -level error
			write-log -message "Error Code: $($error[0].invocationinfo.invocationname)" -level error
			write-log -message "Error Message: $($error[0].exception.message)" -level error
		}
	}
	$msg = '{0} : {1} : {2}' -f (get-date -format "yyyy-MM-dd HH:mm:ss"), $level.toupper(), $message
	if ($my_debug) { if (!(test-path -path $my_logfile)) {  $msg | out-file -filepath $my_logfile -force } else { $msg | out-file -filepath $my_logfile -append } }
	switch ($level) {
		"error"  { if ($my_debug) { write-host $msg -foregroundcolor red } else { $log.writeentry($message, 'Error', $eventid);  } }
		"warn"  { if ($my_debug) { write-host $msg -foregroundcolor yellow } else { $log.writeentry($message, 'Warning', $eventid);  } }
		"info"  { if ($my_debug) { write-host $msg -foregroundcolor white } else { $log.writeentry($message, 'Information', $eventid);  } }
		"debug"  { if ($my_debug) { write-host $msg -foregroundcolor cyan } else { $log.writeentry($message, 'Information', $eventid);  } }
	}
}
function create-aesmanagedobject($key, $iv) {
    $aesmanaged = new-object "system.security.cryptography.aesmanaged"
    $aesmanaged.mode = [system.security.cryptography.ciphermode]::cbc
    $aesmanaged.padding = [system.security.cryptography.paddingmode]::zeros
    $aesmanaged.blocksize = 128
    $aesmanaged.keysize = 256
    if ($iv) {
        if ($iv.gettype().name -eq "string") {
            $aesmanaged.iv = [system.convert]::frombase64string($iv)
        } else {
            $aesmanaged.iv = $iv
        }
    }
    if ($key) {
        if ($key.gettype().name -eq "string") {
            $aesmanaged.key = [system.convert]::frombase64string($key)
        } else {
            $aesmanaged.key = $key
        }
    }
    $aesmanaged
}
function decrypt-string($key, $encryptedstringwithiv) {
	$bytes = [system.convert]::frombase64string($encryptedstringwithiv)
	$iv = $bytes[0..15]
	$aesmanaged = create-aesmanagedobject $key $iv
	$decryptor = $aesmanaged.createdecryptor();
	$unencrypteddata = $decryptor.transformfinalblock($bytes, 16, $bytes.length - 16);
	$aesmanaged.clear()
	$aesmanaged.dispose()
	[system.text.encoding]::utf8.getstring($unencrypteddata).trim([char]0)
}
function isonline([string]$my_testcomputer) {
	$my_pingsuccess = $false
	try { $my_ping = new-object system.net.networkinformation.ping; $my_pingtest = $my_ping.send($my_testcomputer) }
	catch{ }
	if ($my_pingtest.status.tostring() -eq "Success") { return $true } else { return $false	}
 }
[string]$my_computername = [system.net.dns]::gethostname() # Get local hostname
if ($my_debug) {
	write-log -message "Nutanix Guest Tools Active Directory Installer Script -------- $(get-date -uformat '%m/%d/%Y %r')" -level debug
	write-log -message "Variables:" -level debug
	write-log -message " -VARS: Computer Name = $($my_computername)" -level debug
	write-log -message " -VARS: Username = $($my_prism_username)" -level debug
	write-log -message " -VARS: Write To Event Log = $($my_write_to_event_log)" -level debug
	write-log -message " -VARS: Dry Run (No installation) = $($my_dry_run)" -level debug
	write-log -message " -VARS: REST Timeout = $($my_rest_timeout)" -level debug
	write-log -message " -VARS: ISO Retries = $($my_max_iso_retries)" -level debug
	write-log -message " -VARS: Log Directory = $($my_log_directory)" -level debug
	write-log -message " -VARS: Log File = $($my_logfile)" -level debug
}
# Check that the script is running with elevated permissions.
$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (!($currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator))) {
	write-log -message "Error Code: 0x80040005" -level error
	write-log -message "Error Message: This script must run with elevated credentials." -level error
	exit
}
# PREPARE RESTFUL API CALLS
write-log -message "Preparing to run REST API calls." -level debug
$bytes = [system.text.encoding]::ascii.getbytes("$($my_prism_username):$(decrypt-string $my_hashkey $my_prism_pass)")
$base64 = [system.convert]::tobase64string($bytes)
$basicauthvalue = "basic $base64"
$headers = @{
	'accept' = 'application/json'
	'authorization' = $basicauthvalue
	'content-type' = 'application/json'
}
if (-not ([System.Management.Automation.PSTypeName]'ServerCertificateValidationCallback').Type) {
	$certCallback = @"
		using System;
		using System.Net;
		using System.Net.Security;
		using System.Security.Cryptography.X509Certificates;
		public class ServerCertificateValidationCallback {
			public static void Ignore() {
				if(ServicePointManager.ServerCertificateValidationCallback ==null) { ServicePointManager.ServerCertificateValidationCallback += delegate ( Object obj, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors )  { return true; }; }
			}
		}
"@
    Add-Type $certCallback
 }
[servercertificatevalidationcallback]::ignore()
[net.servicepointmanager]::securityprotocol = [net.securityprotocoltype]::tls12
# Disable IE "first-run" dialog as it prevents the use of the invoke-webrequest method.
try {
	$my_keypath = 'Registry::HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Internet Explorer\Main'
	if (!(test-path $my_keypath)) { new-item $my_keypath -force | out-null }
	if (test-path $my_keypath) { set-itemproperty -path $my_keypath -name "DisableFirstRunCustomize" -value 1 }
}
catch {
	write-log -message "Error Line: $($error[0].invocationinfo.scriptlinenumber)" -level error
	write-log -message "Error Code: $($error[0].invocationinfo.invocationname)" -level error
	write-log -message "Error Message: $($error[0].exception.message)" -level error
}
#
write-log -message "Looking for local NGT installation." -level debug
get-wmiobject -class win32_product | % { if ($_.Name -match "nutanix") { $ntnx_cnt++ } } # Check if NGT is installed.
write-log -message "Local NGT products installed: $($ntnx_cnt)" -level debug
[bool]$my_continue = $true
foreach ($cluster_vip in $my_cluster_vip_addresses) {
	# MAKE RESTFUL API CALLS TO MOUNT NGT ISO
	if (!($my_continue)) { break; }
	write-log -message "Looking for $($my_computername) on $($cluster_vip)" -level debug
	write-log -message "Checking if $($cluster_vip) is reachable..." -level debug
	if (!(isonline($cluster_vip.trim()))) { write-log -message "Cluster $($cluster_vip) is not reachable..." -level info; continue }
	$my_data = "{ ""filter"": ""vm_name==$($my_computername)"" }"
	$rest_string = "/api/nutanix/v3/vms/list"
	$restapiuri = "https://$($cluster_vip):9440$($rest_string)"
	write-log -message "REST URI: $($restapiuri)" -level debug
	write-log -message "REST Data: $($my_data)" -level debug
	write-log -message "Sending REST POST request to locate VM UUID..." -level debug
	try {
			$my_response = invoke-webrequest -uri $restapiuri -method post -body $my_data -headers $headers -timeoutsec $my_rest_timeout -credential $null
			write-log -message "Sent REST payload..." -level debug
		}
		catch {
			write-log -message "Error Line: $($error[0].invocationinfo.scriptlinenumber)" -level error
			write-log -message "Error Code: $($error[0].invocationinfo.invocationname)" -level error
			write-log -message "Error Message: $($error[0].exception.message)" -level error
		}
		$my_response = $my_response.content | convertfrom-json
		$my_ngt_response = $my_response.entities.spec | convertto-json -depth 10
		$my_vm_uuid = $my_response.entities.metadata.uuid

		if (!($my_vm_uuid)) { write-log -message "VM / $($my_computername) not found on $($cluster_vip)." -level debug; continue }
		write-log -message "Performing GET against: $($my_vm_uuid)" -level debug
		$rest_string = "/api/nutanix/v3/vms/$($my_vm_uuid)"
		$restapiuri = "https://$($cluster_vip):9440$($rest_string)"
		write-log -message "REST URI: $($restapiuri)" -level debug
		write-log -message "Sending REST GET request to collect data for $($my_vm_uuid)." -level debug
		try {
				$my_get_response = invoke-webrequest -uri $restapiuri -method get -body $null -headers $headers -timeoutsec $my_rest_timeout -credential $null
				$my_converted_get_response = $my_get_response.content | convertfrom-json
				write-log -message "Sent REST payload..." -level debug
				switch ([int]$my_get_response.statuscode) {
					200  { write-log -message "REST Response ($($my_get_response.statuscode)) OK..." -level debug }
					default { write-log -message "GET Response: $($my_ngt_response)" -level debug; write-log -message "Error: $($my_get_response.statuscode)" -level error }
				}
			}
			catch {
				write-log -message "Error Line: $($error[0].invocationinfo.scriptlinenumber)" -level error
				write-log -message "Error Code: $($error[0].invocationinfo.invocationname)" -level error
				write-log -message "Error Message: $($error[0].exception.message)" -level error
				break
			}
		[int]$my_spec_version = $my_converted_get_response.metadata.spec_version | convertto-json

		$my_spec_version = ($my_spec_version+1)
		write-log -message "VM UUID for $($my_computername): $($my_vm_uuid)" -level debug
		if ($my_converted_get_response.spec.resources.guest_tools) {
			write-log -message "NGT JSON configuration found. Updating..." -level debug
			$my_converted_get_response.spec.resources.guest_tools.nutanix_guest_tools.iso_mount_state = "MOUNTED"
			$my_converted_get_response.spec.resources.guest_tools.nutanix_guest_tools.state = "ENABLED"
		}
		else {
			write-log -message "NGT JSON configuration not found. Creating..." -level debug
			$my_node_to_add = "{ ""nutanix_guest_tools"":  { ""iso_mount_state"":  ""MOUNTED"", ""state"":  ""ENABLED"", ""ngt_state"":  ""UNINSTALLED"" } }"
			$my_converted_get_response.spec.resources | add-member -type noteproperty -name guest_tools -value (convertfrom-json $my_node_to_add) #add node
		}
		$my_payload = new-object -type psobject
		$my_payload | add-member -type noteproperty -name spec -value $my_converted_get_response.spec
		$my_payload | add-member -type noteproperty -name api_version -value $my_converted_get_response.api_version
		$my_payload | add-member -type noteproperty -name metadata -value $my_converted_get_response.metadata

		if (($my_vm_uuid) -and ($my_vm_uuid -ne "Not Found")) {
			$my_continue = $false
			$rest_string = "/api/nutanix/v3/vms/$($my_response.entities.metadata.uuid)"
			$restapiuri = "https://$($cluster_vip):9440$($rest_string)"
			write-log -message "REST URI: $($restapiuri)" -level debug
			$my_payload_data = ($my_payload | convertto-json -depth 20)
			try {
				$response = invoke-webrequest -uri $restapiuri -method put -body $my_payload_data -headers $headers -contenttype "application/json"
				write-log -message "Sent REST payload..." -level debug
				switch ([int]$response.statuscode) {
					202  { write-log -message "Nutanix Guest Tools ISO mount pending..." -level info }
					default { write-log -message "PUT Payload: $($my_ngt_response)" -level debug; write-log -message "Error: $($response.statuscode)" -level error }
				}
			}
			catch {
				write-log -message "PUT Payload: $($my_ngt_response)" -level debug;
				write-log -message "Error Line: $($error[0].invocationinfo.scriptlinenumber)" -level error
				write-log -message "Error Code: $($error[0].invocationinfo.invocationname)" -level error
				write-log -message "Error Message: $($error[0].exception.message)" -level error
				break
			}
		}
		else {
			write-log -message "Unable to find $($my_computername) on cluster $($cluster_vip)." -level info
			continue
		}

	# WAIT FOR NGT ISO TO MOUNT
	if (!($my_max_iso_retries)) { write-log -message "Maximum Retries is not set." -level error ;exit }
	$i = 0
	do {
		write-log -message "Checking for NGT ISO mount..." -level debug
		$my_driveid = (get-ciminstance win32_logicaldisk | ?{ $_.volumename -eq "nutanix_tools" }).deviceid
		if ($my_driveid) { write-log -message "Found NGT on $($my_driveid)" -level debug; break }
		$i++; start-sleep -s 5
	} while ($i -lt $my_max_iso_retries)
	# INSTALL OR UPGRADE NGT
	write-log -message "Nutanix Package Count: $($ntnx_cnt)" -level debug
	if ($ntnx_cnt -ne 6) {
		write-log -message "Nutanix Guest Tools is not installed..." -level info
		$my_driveid = (get-ciminstance win32_logicaldisk | ?{ $_.volumename -eq "nutanix_tools" }).deviceid
		$my_date = get-date -format 'MMddyyyy_HHmm'
		$my_files = @("Nutanix_Guest_Tools*")
		if ($my_driveid) {
			try {
				if (test-path "$($my_driveid)\setup.exe") {
					if ($my_dry_run) { write-log -message "[Dry Run] - Starting Nutanix Guest Tools Installer..." -level info }
					else { write-log -message "Starting Nutanix Guest Tools Installer..." -level info }
					write-log -message "Running Process: $($my_driveid)\setup.exe /quiet /norestart ACCEPTEULA=YES IGNOREALLWARNINGS=yes log $($my_log_directory)\NGT\" -level debug
					if (!($my_dry_run)) {
						$process = start-process "$($my_driveid)\setup.exe" -windowstyle Hidden -argumentlist "/quiet /norestart ACCEPTEULA=YES IGNOREALLWARNINGS=yes log $($my_log_directory)\NGT\" -passthru -wait
						if ($process.exitcode -eq 0) {
							write-log -message "Installation Succeeded..." -level info; break
						}
						else {
							write-log -message "Installation failed, non-zero exit code..." -level warn; break
						}
					}
				}
				else {
						write-log -message "Installation failed, setup executable not found..." -level warn; break
				}
			}
			catch {
				write-log -message "Installation failed...`r`n$($_)" -level error; break
			}
			new-item -itemtype directory -force -path "$($my_log_directory)\NGT" | out-null; get-childitem -recurse ($env:temp) -include ($my_files) | move-item -destination "$($my_log_directory)\NGT\" -ea silentlycontinue
		}
		else {
			write-log -message "Installation is needed, but ISO not mounted..." -level warn; break
		}
	}
	else {
		write-log -message "Nutanix Guest Tools is already installed..." -level info
		# UPGRADE NGT
		$my_driveid = (get-ciminstance win32_logicaldisk | ?{ $_.volumename -eq "nutanix_tools" }).deviceid
		$my_files = @("Nutanix_Guest_Tools*")
		if ($my_driveid) {
			write-log -message "Checking Nutanix Guest Tools for upgrade..." -level info
			if (test-path "$($my_driveid)\setup.exe") {
				$my_setup_version = [system.diagnostics.fileversioninfo]::getversioninfo("$($my_driveid)\setup.exe").fileversion
				$registry = get-childitem "hklm:\software\wow6432node\microsoft\windows\currentversion\uninstall" -recurse
				foreach ($a in $registry) { $a.property | foreach-object { if ($a.getvalue($_) -eq "nutanix guest tools") { $my_installed_version = (get-itemproperty -path "registry::$($a.name)" -name displayversion).displayversion } } }
				if (($my_installed_version) -and ($my_setup_version)) {
					write-log -message "Installed NGT version: $($my_installed_version) - ISO NGT version: $($my_setup_version)" -level debug
					if ($my_setup_version -gt $my_installed_version) {
						if ($my_dry_run) { write-log -message "[Dry Run] - Nutanix Guest Tools needs upgrading..." -level info }
						else { write-log -message "Nutanix Guest Tools needs upgrading..." -level info }
						write-log -message "Running Process: $($my_driveid)\setup.exe /quiet /norestart ACCEPTEULA=YES IGNOREALLWARNINGS=yes log $($my_log_directory)\NGT\" -level debug
						if (!($my_dry_run)) {
							$process = start-process "$($my_driveid)\setup.exe" -windowstyle hidden -argumentlist "/quiet /norestart ACCEPTEULA=YES IGNOREALLWARNINGS=yes log $($my_log_directory)\NGT\" -passthru -wait
		                    if ($process.exitcode -eq 0) {
		                        write-log -message "Nutanix Guest Tools upgrade succeeded." -level info; break
	                  		}
		                    else {
		                        write-log -message "Nutanix Guest Tools upgrade failed, non-zero exit code." -level warn; break
		                    }
						}
					}
					else {
						write-log -message "Nutanix Guest Tools does not need Upgrading..." -level info; break
					}
				}
				else {
					write-log -message "Cannot get versioning information for Nutanix Guest Tools upgrade check..." -level info; break
				}
			}
		}
	}
}
$erroractionpreference = $my_temperract
write-log -message "Script Completed..." -level debug
[gc]::collect()

exit