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