Before you need to reboot a VM, or do some destructive maintenance on there, it is a good practice to at least tell the user(s) of that VM what is going to happen. But how do you address the users of a VM? They can be connected to a console (local) or via a RDP session (remote). And how do you get their reply back?
Exactly such a question appeared in the VMTN PowerCLI Community recently. And after some digging, it seems that is possible through a PowerShell script that uses the Remote Desktop Services API, provided through the wtsapi32.dll. Note that the VMs we are looking at, all are running a Windows guest OS.
Background
After using some Google-fu I found some examples of scripts that use the wtsapi32.dll to find users connected to a station. There is a script called Send-TSMessageB
That was part 1 of solving the question.
The second part was that I couldn’t get my code to work. I was using Invoke-VMScript to run the script on the target VM, but I kept hitting inexplicable errors. Then I finally realised that I was hitting the maximum ScriptText length. Unfortunately my Invoke-VMScriptPlus function, which does not suffer from that ScriptText length problem, did not work for VMs with a Windows guest OS.
As a fix I started writing Invoke-VMScriptPlus V2, which handled sending scripts to VMs with a Windows guest OS.
And finally I got the message box working!
The Code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
$code = @' $server = "localhost" $messageTitle = "Reboot Computer" $ButtonSet = 3 $message = "This computer will be rebooted in #timeMin# minutes`n`n[Yes] To reboot now`n[No] To allow the scheduled reboot`n[Cancel] To cancel the reboot" $timeout = #timeSec#; $wtssig = @" namespace mystruct { using System; using System.Runtime.InteropServices; [StructLayout(LayoutKind.Sequential)] public struct WTS_SESSION_INFO { public Int32 SessionID; [MarshalAs(UnmanagedType.LPStr)] public String pWinStationName; public WTS_CONNECTSTATE_CLASS State; } public enum WTS_CONNECTSTATE_CLASS { WTSActive, WTSConnected, WTSConnectQuery, WTSShadow, WTSDisconnected, WTSIdle, WTSListen, WTSReset, WTSDown, WTSInit } } "@ add-type $wtssig $wtsenumsig = @" [DllImport("wtsapi32.dll", SetLastError=true)] public static extern int WTSEnumerateSessions( System.IntPtr hServer, int Reserved, int Version, ref System.IntPtr ppSessionInfo, ref int pCount); "@ $wtsopensig = @" [DllImport("wtsapi32.dll", SetLastError=true)] public static extern IntPtr WTSOpenServer(string pServerName); "@ $wtsSendMessagesig = @" [DllImport("wtsapi32.dll", SetLastError = true)] public static extern bool WTSSendMessage( IntPtr hServer, [MarshalAs(UnmanagedType.I4)] int SessionId, String pTitle, [MarshalAs(UnmanagedType.U4)] int TitleLength, String pMessage, [MarshalAs(UnmanagedType.U4)] int MessageLength, [MarshalAs(UnmanagedType.U4)] int Style, [MarshalAs(UnmanagedType.U4)] int Timeout, [MarshalAs(UnmanagedType.U4)] out int pResponse, bool bWait); "@ ` $wtsenum = add-type -MemberDefinition $wtsenumsig -Name PSWTSEnumerateSessions -Namespace GetLoggedOnUsers -PassThru $wtsOpen = add-type -MemberDefinition $wtsopensig -name PSWTSOpenServer -Namespace GetLoggedOnUsers -PassThru $wtsmessage = Add-Type -MemberDefinition $wtsSendMessagesig -name PSWTSSendMessage -Namespace GetLoggedOnUsers -PassThru [long]$count = 0 [long]$ppSessionInfo = 0 $server = $wtsOpen::WTSOpenServer($server) [long]$retval = $wtsenum::WTSEnumerateSessions($server,0,1,[ref]$ppSessionInfo,[ref]$count) $datasize = [system.runtime.interopservices.marshal]::SizeOf([System.Type][mystruct.WTS_SESSION_INFO]) $Responses = @() if ($retval -ne 0){ for ($i = 0; $i -lt $count; $i++){ $element = [system.runtime.interopservices.marshal]::PtrToStructure($ppSessionInfo + ($datasize * $i),[System.type][mystruct.WTS_SESSION_INFO]) if($element.State -eq 'WTSActive'){ $resp = "" $wtsmessage::WTSSendMessage($server, $element.SessionID,$messageTitle,$messageTitle.Length,$message,$message.Length,3,$timeout,[ref]$resp,$true) | Out-Null $responses += "Session $($element.SessionId) selected $resp" } } } $responses '@ $user = 'local\administrator' $pswd = 'SuperSecret1!' $sPswd = ConvertTo-SecureString -String $pswd -AsPlainText -Force $cred = New-Object System.Management.Automation.PSCredential -ArgumentList $user,$sPswd $timeMin = 1 $timeSec = $timeMin * 60 $code = $code.Replace('#timeMin#',$timeMin) $code = $code.Replace('#timeSec#',$timeSec) $sInvP = @{ VM = 'vEng' ScriptType = 'PowerShell' ScriptText = $code GuestCredential = $cred } $result = Invoke-VMScriptPlus @sInvP switch($result.ScriptOutput.TrimEnd("`r`n")){ '3' {'Abort'} # Cancel reboot '6' {'Yes'} # Reboot now '7' {'No'} # Reboot after time elapses '32000' {'Timed out'} # Time expired, reboot now } |
Annotations
Line 1: The actual PowerShell script we will run inside the guest OS, is presented as a here-string
Line 4: We are using a MessageBox with three buttons: Yes, No and Cancel. Depending on which button the user selected, a different value will be returned.
Line 5,6: Since a here-string is just that, a string, we can find and replace specific patterns in there. We use the patterns #timeMin# and #timeSec# to fill in the actual time.
Line 8-74: To be able to use the Remote Desktop Services API methods, we have to declare the CSharp code with the Add-Type cmdlet. Note that CSharp is the default value for the Language parameter on the Add-Type cmdlet.
Line 79: Since we are running the code inside the VM’s guest OS, we use localhost as the servername
Line 80: We use the WTSEnumerateSessions method to find out how many sessions are present. On a side note, whenever you are looking for sample snippets for specific API methods, always have a look at the pInvoke.Net website, they have tons of sample snippets, including PowerShell code. Have for example a look at WTSEnumerateSessions.
Line 86: We are only interested in ‘active’ sessions.
Line 88: This line will send the actual MessageBox to the session, and wait for the reply, or until the timeout expires. That means that if you have multiple sessions ope to the same VM, that the total time before the script comes back, will be #number-of-sessions X #timeout value. This is a drawback of the script that I haven’t been able yet to resolve. A possibility I’m looking at is to send the MessageBox through a Start-Job to each active session.
Line 94: The responses for each active session are returned
Sample Use
Take note that there are always sessions present, the MessageBox only needs to be displayed in active sessions. The following shows the sessions that are present when there is one active RDP session. Those are the ones that have the WTSActive state.
When we run the script on a target VM, the following MessageBox is displayed to the user of each active session.
The user can press any of the three available buttons, or the MessageBox can just time out. Depending on what happens, a different value will be returned.
Action | Value |
Cancel button | 3 |
Yes button | 6 |
No button | 7 |
Timeout | 32000 |
Once you have this value the corresponding action can be taken.
The MessageBox works for RDP sessions as well as for local sessions, as the following two partial screenshots demonstrate.
Local
RDP
Is this the ideal solution for the original question?
As long as there is only one session to a VM, then yes. If there are multiple sessions, the scripts needs more work to cope with that. Suggestions and improvements are always welcome.
Enjoy!
Jim
A lot of work. Why so many compiles.
The API has been wrapped and fully decoded – see PowerShellGet for versions.
Why not use the following:
msg * /Server:TsServer1 /Time:15 ‘This server will reboot in 14 seconds’
LucD
Well, there are a couple of reasons to not use msg.
Krish MD
Thanks very much for sharing. Something I was looking for as well.
Lex van der Horst
Thanks for this nice article, will check it out !
Chetna Tanwani
Great idea to poll the End user views before actually taking the action. Thanks for sharing!