1"""Contains client side logic of WinRM SOAP protocol implementation""" 2from __future__ import unicode_literals 3import base64 4import uuid 5 6import xml.etree.ElementTree as ET 7import xmltodict 8 9from six import text_type 10 11from winrm.transport import Transport 12from winrm.exceptions import WinRMError, WinRMTransportError, WinRMOperationTimeoutError 13 14xmlns = { 15 'soapenv': 'http://www.w3.org/2003/05/soap-envelope', 16 'soapaddr': 'http://schemas.xmlsoap.org/ws/2004/08/addressing', 17 'wsmanfault': "http://schemas.microsoft.com/wbem/wsman/1/wsmanfault" 18} 19 20 21class Protocol(object): 22 """This is the main class that does the SOAP request/response logic. There 23 are a few helper classes, but pretty much everything comes through here 24 first. 25 """ 26 DEFAULT_READ_TIMEOUT_SEC = 30 27 DEFAULT_OPERATION_TIMEOUT_SEC = 20 28 DEFAULT_MAX_ENV_SIZE = 153600 29 DEFAULT_LOCALE = 'en-US' 30 31 def __init__( 32 self, endpoint, transport='plaintext', username=None, 33 password=None, realm=None, service="HTTP", keytab=None, 34 ca_trust_path='legacy_requests', cert_pem=None, cert_key_pem=None, 35 server_cert_validation='validate', 36 kerberos_delegation=False, 37 read_timeout_sec=DEFAULT_READ_TIMEOUT_SEC, 38 operation_timeout_sec=DEFAULT_OPERATION_TIMEOUT_SEC, 39 kerberos_hostname_override=None, 40 message_encryption='auto', 41 credssp_disable_tlsv1_2=False, 42 send_cbt=True, 43 proxy='legacy_requests', 44 ): 45 """ 46 @param string endpoint: the WinRM webservice endpoint 47 @param string transport: transport type, one of 'plaintext' (default), 'kerberos', 'ssl', 'ntlm', 'credssp' # NOQA 48 @param string username: username 49 @param string password: password 50 @param string realm: unused 51 @param string service: the service name, default is HTTP 52 @param string keytab: the path to a keytab file if you are using one 53 @param string ca_trust_path: Certification Authority trust path. If server_cert_validation is set to 'validate': 54 'legacy_requests'(default) to use environment variables, 55 None to explicitly disallow any additional CA trust path 56 Any other value will be considered the CA trust path to use. 57 @param string cert_pem: client authentication certificate file path in PEM format # NOQA 58 @param string cert_key_pem: client authentication certificate key file path in PEM format # NOQA 59 @param string server_cert_validation: whether server certificate should be validated on Python versions that suppport it; one of 'validate' (default), 'ignore' #NOQA 60 @param bool kerberos_delegation: if True, TGT is sent to target server to allow multiple hops # NOQA 61 @param int read_timeout_sec: maximum seconds to wait before an HTTP connect/read times out (default 30). This value should be slightly higher than operation_timeout_sec, as the server can block *at least* that long. # NOQA 62 @param int operation_timeout_sec: maximum allowed time in seconds for any single wsman HTTP operation (default 20). Note that operation timeouts while receiving output (the only wsman operation that should take any significant time, and where these timeouts are expected) will be silently retried indefinitely. # NOQA 63 @param string kerberos_hostname_override: the hostname to use for the kerberos exchange (defaults to the hostname in the endpoint URL) 64 @param bool message_encryption_enabled: Will encrypt the WinRM messages if set to True and the transport auth supports message encryption (Default True). 65 @param string proxy: Specify a proxy for the WinRM connection to use. 'legacy_requests'(default) to use environment variables, None to disable proxies completely or the proxy URL itself. 66 """ 67 68 try: 69 read_timeout_sec = int(read_timeout_sec) 70 except ValueError as ve: 71 raise ValueError("failed to parse read_timeout_sec as int: %s" % str(ve)) 72 73 try: 74 operation_timeout_sec = int(operation_timeout_sec) 75 except ValueError as ve: 76 raise ValueError("failed to parse operation_timeout_sec as int: %s" % str(ve)) 77 78 if operation_timeout_sec >= read_timeout_sec or operation_timeout_sec < 1: 79 raise WinRMError("read_timeout_sec must exceed operation_timeout_sec, and both must be non-zero") 80 81 self.read_timeout_sec = read_timeout_sec 82 self.operation_timeout_sec = operation_timeout_sec 83 self.max_env_sz = Protocol.DEFAULT_MAX_ENV_SIZE 84 self.locale = Protocol.DEFAULT_LOCALE 85 86 self.transport = Transport( 87 endpoint=endpoint, username=username, password=password, 88 realm=realm, service=service, keytab=keytab, 89 ca_trust_path=ca_trust_path, cert_pem=cert_pem, 90 cert_key_pem=cert_key_pem, read_timeout_sec=self.read_timeout_sec, 91 server_cert_validation=server_cert_validation, 92 kerberos_delegation=kerberos_delegation, 93 kerberos_hostname_override=kerberos_hostname_override, 94 auth_method=transport, 95 message_encryption=message_encryption, 96 credssp_disable_tlsv1_2=credssp_disable_tlsv1_2, 97 send_cbt=send_cbt, 98 proxy=proxy, 99 ) 100 101 self.username = username 102 self.password = password 103 self.service = service 104 self.keytab = keytab 105 self.ca_trust_path = ca_trust_path 106 self.server_cert_validation = server_cert_validation 107 self.kerberos_delegation = kerberos_delegation 108 self.kerberos_hostname_override = kerberos_hostname_override 109 self.credssp_disable_tlsv1_2 = credssp_disable_tlsv1_2 110 111 def open_shell(self, i_stream='stdin', o_stream='stdout stderr', 112 working_directory=None, env_vars=None, noprofile=False, 113 codepage=437, lifetime=None, idle_timeout=None): 114 """ 115 Create a Shell on the destination host 116 @param string i_stream: Which input stream to open. Leave this alone 117 unless you know what you're doing (default: stdin) 118 @param string o_stream: Which output stream to open. Leave this alone 119 unless you know what you're doing (default: stdout stderr) 120 @param string working_directory: the directory to create the shell in 121 @param dict env_vars: environment variables to set for the shell. For 122 instance: {'PATH': '%PATH%;c:/Program Files (x86)/Git/bin/', 'CYGWIN': 123 'nontsec codepage:utf8'} 124 @returns The ShellId from the SOAP response. This is our open shell 125 instance on the remote machine. 126 @rtype string 127 """ 128 req = {'env:Envelope': self._get_soap_header( 129 resource_uri='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd', # NOQA 130 action='http://schemas.xmlsoap.org/ws/2004/09/transfer/Create')} 131 header = req['env:Envelope']['env:Header'] 132 header['w:OptionSet'] = { 133 'w:Option': [ 134 { 135 '@Name': 'WINRS_NOPROFILE', 136 '#text': str(noprofile).upper() # TODO remove str call 137 }, 138 { 139 '@Name': 'WINRS_CODEPAGE', 140 '#text': str(codepage) # TODO remove str call 141 } 142 ] 143 } 144 145 shell = req['env:Envelope'].setdefault( 146 'env:Body', {}).setdefault('rsp:Shell', {}) 147 shell['rsp:InputStreams'] = i_stream 148 shell['rsp:OutputStreams'] = o_stream 149 150 if working_directory: 151 # TODO ensure that rsp:WorkingDirectory should be nested within rsp:Shell # NOQA 152 shell['rsp:WorkingDirectory'] = working_directory 153 # TODO check Lifetime param: http://msdn.microsoft.com/en-us/library/cc251546(v=PROT.13).aspx # NOQA 154 # if lifetime: 155 # shell['rsp:Lifetime'] = iso8601_duration.sec_to_dur(lifetime) 156 # TODO make it so the input is given in milliseconds and converted to xs:duration # NOQA 157 if idle_timeout: 158 shell['rsp:IdleTimeOut'] = idle_timeout 159 if env_vars: 160 # the rsp:Variable tag needs to be list of variables so that all 161 # environment variables in the env_vars dict are set on the shell 162 env = shell.setdefault('rsp:Environment', {}).setdefault('rsp:Variable', []) 163 for key, value in env_vars.items(): 164 env.append({'@Name': key, '#text': value}) 165 166 res = self.send_message(xmltodict.unparse(req)) 167 168 # res = xmltodict.parse(res) 169 # return res['s:Envelope']['s:Body']['x:ResourceCreated']['a:ReferenceParameters']['w:SelectorSet']['w:Selector']['#text'] 170 root = ET.fromstring(res) 171 return next( 172 node for node in root.findall('.//*') 173 if node.get('Name') == 'ShellId').text 174 175 # Helper method for building SOAP Header 176 def _get_soap_header( 177 self, action=None, resource_uri=None, shell_id=None, 178 message_id=None): 179 if not message_id: 180 message_id = uuid.uuid4() 181 header = { 182 '@xmlns:xsd': 'http://www.w3.org/2001/XMLSchema', 183 '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', 184 '@xmlns:env': xmlns['soapenv'], 185 186 '@xmlns:a': xmlns['soapaddr'], 187 '@xmlns:b': 'http://schemas.dmtf.org/wbem/wsman/1/cimbinding.xsd', 188 '@xmlns:n': 'http://schemas.xmlsoap.org/ws/2004/09/enumeration', 189 '@xmlns:x': 'http://schemas.xmlsoap.org/ws/2004/09/transfer', 190 '@xmlns:w': 'http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd', 191 '@xmlns:p': 'http://schemas.microsoft.com/wbem/wsman/1/wsman.xsd', 192 '@xmlns:rsp': 'http://schemas.microsoft.com/wbem/wsman/1/windows/shell', # NOQA 193 '@xmlns:cfg': 'http://schemas.microsoft.com/wbem/wsman/1/config', 194 195 'env:Header': { 196 'a:To': 'http://windows-host:5985/wsman', 197 'a:ReplyTo': { 198 'a:Address': { 199 '@mustUnderstand': 'true', 200 '#text': 'http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous' # NOQA 201 } 202 }, 203 'w:MaxEnvelopeSize': { 204 '@mustUnderstand': 'true', 205 '#text': '153600' 206 }, 207 'a:MessageID': 'uuid:{0}'.format(message_id), 208 'w:Locale': { 209 '@mustUnderstand': 'false', 210 '@xml:lang': 'en-US' 211 }, 212 'p:DataLocale': { 213 '@mustUnderstand': 'false', 214 '@xml:lang': 'en-US' 215 }, 216 # TODO: research this a bit http://msdn.microsoft.com/en-us/library/cc251561(v=PROT.13).aspx # NOQA 217 # 'cfg:MaxTimeoutms': 600 218 # Operation timeout in ISO8601 format, see http://msdn.microsoft.com/en-us/library/ee916629(v=PROT.13).aspx # NOQA 219 'w:OperationTimeout': 'PT{0}S'.format(int(self.operation_timeout_sec)), 220 'w:ResourceURI': { 221 '@mustUnderstand': 'true', 222 '#text': resource_uri 223 }, 224 'a:Action': { 225 '@mustUnderstand': 'true', 226 '#text': action 227 } 228 } 229 } 230 if shell_id: 231 header['env:Header']['w:SelectorSet'] = { 232 'w:Selector': { 233 '@Name': 'ShellId', 234 '#text': shell_id 235 } 236 } 237 return header 238 239 def send_message(self, message): 240 # TODO add message_id vs relates_to checking 241 # TODO port error handling code 242 try: 243 resp = self.transport.send_message(message) 244 return resp 245 except WinRMTransportError as ex: 246 try: 247 # if response is XML-parseable, it's probably a SOAP fault; extract the details 248 root = ET.fromstring(ex.response_text) 249 except Exception: 250 # assume some other transport error; raise the original exception 251 raise ex 252 253 fault = root.find('soapenv:Body/soapenv:Fault', xmlns) 254 if fault is not None: 255 fault_data = dict( 256 transport_message=ex.message, 257 http_status_code=ex.code 258 ) 259 wsmanfault_code = fault.find('soapenv:Detail/wsmanfault:WSManFault[@Code]', xmlns) 260 if wsmanfault_code is not None: 261 fault_data['wsmanfault_code'] = wsmanfault_code.get('Code') 262 # convert receive timeout code to WinRMOperationTimeoutError 263 if fault_data['wsmanfault_code'] == '2150858793': 264 # TODO: this fault code is specific to the Receive operation; convert all op timeouts? 265 raise WinRMOperationTimeoutError() 266 267 fault_code = fault.find('soapenv:Code/soapenv:Value', xmlns) 268 if fault_code is not None: 269 fault_data['fault_code'] = fault_code.text 270 271 fault_subcode = fault.find('soapenv:Code/soapenv:Subcode/soapenv:Value', xmlns) 272 if fault_subcode is not None: 273 fault_data['fault_subcode'] = fault_subcode.text 274 275 error_message = fault.find('soapenv:Reason/soapenv:Text', xmlns) 276 if error_message is not None: 277 error_message = error_message.text 278 else: 279 error_message = "(no error message in fault)" 280 281 raise WinRMError('{0} (extended fault data: {1})'.format(error_message, fault_data)) 282 283 def close_shell(self, shell_id, close_session=True): 284 """ 285 Close the shell 286 @param string shell_id: The shell id on the remote machine. 287 See #open_shell 288 @param bool close_session: If we want to close the requests's session. 289 Allows to completely close all TCP connections to the server. 290 @returns This should have more error checking but it just returns true 291 for now. 292 @rtype bool 293 """ 294 try: 295 message_id = uuid.uuid4() 296 req = {'env:Envelope': self._get_soap_header( 297 resource_uri='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd', # NOQA 298 action='http://schemas.xmlsoap.org/ws/2004/09/transfer/Delete', 299 shell_id=shell_id, 300 message_id=message_id)} 301 302 # SOAP message requires empty env:Body 303 req['env:Envelope'].setdefault('env:Body', {}) 304 305 res = self.send_message(xmltodict.unparse(req)) 306 root = ET.fromstring(res) 307 relates_to = next( 308 node for node in root.findall('.//*') 309 if node.tag.endswith('RelatesTo')).text 310 finally: 311 # Close the transport if we are done with the shell. 312 # This will ensure no lingering TCP connections are thrown back into a requests' connection pool. 313 if close_session: 314 self.transport.close_session() 315 316 # TODO change assert into user-friendly exception 317 assert uuid.UUID(relates_to.replace('uuid:', '')) == message_id 318 319 def run_command( 320 self, shell_id, command, arguments=(), console_mode_stdin=True, 321 skip_cmd_shell=False): 322 """ 323 Run a command on a machine with an open shell 324 @param string shell_id: The shell id on the remote machine. 325 See #open_shell 326 @param string command: The command to run on the remote machine 327 @param iterable of string arguments: An array of arguments for this 328 command 329 @param bool console_mode_stdin: (default: True) 330 @param bool skip_cmd_shell: (default: False) 331 @return: The CommandId from the SOAP response. 332 This is the ID we need to query in order to get output. 333 @rtype string 334 """ 335 req = {'env:Envelope': self._get_soap_header( 336 resource_uri='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd', # NOQA 337 action='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Command', # NOQA 338 shell_id=shell_id)} 339 header = req['env:Envelope']['env:Header'] 340 header['w:OptionSet'] = { 341 'w:Option': [ 342 { 343 '@Name': 'WINRS_CONSOLEMODE_STDIN', 344 '#text': str(console_mode_stdin).upper() 345 }, 346 { 347 '@Name': 'WINRS_SKIP_CMD_SHELL', 348 '#text': str(skip_cmd_shell).upper() 349 } 350 ] 351 } 352 cmd_line = req['env:Envelope'].setdefault( 353 'env:Body', {}).setdefault('rsp:CommandLine', {}) 354 cmd_line['rsp:Command'] = {'#text': command} 355 if arguments: 356 unicode_args = [a if isinstance(a, text_type) else a.decode('utf-8') for a in arguments] 357 cmd_line['rsp:Arguments'] = u' '.join(unicode_args) 358 359 res = self.send_message(xmltodict.unparse(req)) 360 root = ET.fromstring(res) 361 command_id = next( 362 node for node in root.findall('.//*') 363 if node.tag.endswith('CommandId')).text 364 return command_id 365 366 def cleanup_command(self, shell_id, command_id): 367 """ 368 Clean-up after a command. @see #run_command 369 @param string shell_id: The shell id on the remote machine. 370 See #open_shell 371 @param string command_id: The command id on the remote machine. 372 See #run_command 373 @returns: This should have more error checking but it just returns true 374 for now. 375 @rtype bool 376 """ 377 message_id = uuid.uuid4() 378 req = {'env:Envelope': self._get_soap_header( 379 resource_uri='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd', # NOQA 380 action='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Signal', # NOQA 381 shell_id=shell_id, 382 message_id=message_id)} 383 384 # Signal the Command references to terminate (close stdout/stderr) 385 signal = req['env:Envelope'].setdefault( 386 'env:Body', {}).setdefault('rsp:Signal', {}) 387 signal['@CommandId'] = command_id 388 signal['rsp:Code'] = 'http://schemas.microsoft.com/wbem/wsman/1/windows/shell/signal/terminate' # NOQA 389 390 res = self.send_message(xmltodict.unparse(req)) 391 root = ET.fromstring(res) 392 relates_to = next( 393 node for node in root.findall('.//*') 394 if node.tag.endswith('RelatesTo')).text 395 # TODO change assert into user-friendly exception 396 assert uuid.UUID(relates_to.replace('uuid:', '')) == message_id 397 398 def send_command_input(self, shell_id, command_id, stdin_input, end=False): 399 """ 400 Send input to the given shell and command. 401 @param string shell_id: The shell id on the remote machine. 402 See #open_shell 403 @param string command_id: The command id on the remote machine. 404 See #run_command 405 @param string stdin_input: The input unicode string or byte string to be sent. 406 @param bool end: Boolean value which will close the stdin stream. If end=True then the stdin pipe to the 407 remotely running process will be closed causing the next read by the remote process to stdin to return a 408 EndOfFile error; the behavior of each process when this error is encountered is defined by the process, but most 409 processes ( like CMD and powershell for instance) will just exit. Setting this value to 'True' means that no 410 more input will be able to be sent to the process and attempting to do so should result in an error. 411 @return: None 412 """ 413 if isinstance(stdin_input, text_type): 414 stdin_input = stdin_input.encode("437") 415 req = {'env:Envelope': self._get_soap_header( 416 resource_uri='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd', # NOQA 417 action='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Send', # NOQA 418 shell_id=shell_id)} 419 stdin_envelope = req['env:Envelope'].setdefault('env:Body', {}).setdefault( 420 'rsp:Send', {}).setdefault('rsp:Stream', {}) 421 stdin_envelope['@CommandId'] = command_id 422 stdin_envelope['@Name'] = 'stdin' 423 if end: 424 stdin_envelope['@End'] = "true" 425 else: 426 stdin_envelope['@End'] = "false" 427 stdin_envelope['@xmlns:rsp'] = 'http://schemas.microsoft.com/wbem/wsman/1/windows/shell' 428 stdin_envelope['#text'] = base64.b64encode(stdin_input) 429 self.send_message(xmltodict.unparse(req)) 430 431 def get_command_output(self, shell_id, command_id): 432 """ 433 Get the Output of the given shell and command 434 @param string shell_id: The shell id on the remote machine. 435 See #open_shell 436 @param string command_id: The command id on the remote machine. 437 See #run_command 438 #@return [Hash] Returns a Hash with a key :exitcode and :data. 439 Data is an Array of Hashes where the cooresponding key 440 # is either :stdout or :stderr. The reason it is in an Array so so 441 we can get the output in the order it ocurrs on 442 # the console. 443 """ 444 stdout_buffer, stderr_buffer = [], [] 445 command_done = False 446 while not command_done: 447 try: 448 stdout, stderr, return_code, command_done = \ 449 self._raw_get_command_output(shell_id, command_id) 450 stdout_buffer.append(stdout) 451 stderr_buffer.append(stderr) 452 except WinRMOperationTimeoutError: 453 # this is an expected error when waiting for a long-running process, just silently retry 454 pass 455 return b''.join(stdout_buffer), b''.join(stderr_buffer), return_code 456 457 def _raw_get_command_output(self, shell_id, command_id): 458 req = {'env:Envelope': self._get_soap_header( 459 resource_uri='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd', # NOQA 460 action='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Receive', # NOQA 461 shell_id=shell_id)} 462 463 stream = req['env:Envelope'].setdefault('env:Body', {}).setdefault( 464 'rsp:Receive', {}).setdefault('rsp:DesiredStream', {}) 465 stream['@CommandId'] = command_id 466 stream['#text'] = 'stdout stderr' 467 468 res = self.send_message(xmltodict.unparse(req)) 469 root = ET.fromstring(res) 470 stream_nodes = [ 471 node for node in root.findall('.//*') 472 if node.tag.endswith('Stream')] 473 stdout = stderr = b'' 474 return_code = -1 475 for stream_node in stream_nodes: 476 if not stream_node.text: 477 continue 478 if stream_node.attrib['Name'] == 'stdout': 479 stdout += base64.b64decode(stream_node.text.encode('ascii')) 480 elif stream_node.attrib['Name'] == 'stderr': 481 stderr += base64.b64decode(stream_node.text.encode('ascii')) 482 483 # We may need to get additional output if the stream has not finished. 484 # The CommandState will change from Running to Done like so: 485 # @example 486 # from... 487 # <rsp:CommandState CommandId="..." State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Running"/> 488 # to... 489 # <rsp:CommandState CommandId="..." State="http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done"> 490 # <rsp:ExitCode>0</rsp:ExitCode> 491 # </rsp:CommandState> 492 command_done = len([ 493 node for node in root.findall('.//*') 494 if node.get('State', '').endswith('CommandState/Done')]) == 1 495 if command_done: 496 return_code = int( 497 next(node for node in root.findall('.//*') 498 if node.tag.endswith('ExitCode')).text) 499 500 return stdout, stderr, return_code, command_done 501