diff --git a/README.md b/README.md index 6f0cc1d..4b8b2e6 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ For further information see: * **Authentication Type**: The authentication type used for the connection: basic, ntlm, credssp. It can be overwriting at node level using `winrm-authtype` * **Username**: (Optional) Username that will connect to the remote node. This value can be set also at node level or as a job input option (with the name `username) -* **Password Storage Path**: Key storage path of the window's user password. It can be overwriting at node level using `winrm-password-storage-path` +* **Password Storage Path**: Key storage path of the window's user password. It can be overwriting at node level using `winrm-password-storage-path`. + Also the password can be overwritten on the job level using an input secure option called `winrmpassword` * **No SSL Verification**: When set to true SSL certificate validation is not performed. It can be overwriting at node level using `winrm-nossl` * **WinRM Transport Protocol**: WinRM transport protocol (http or https). It can be overwriting at node level using `winrm-transport` * **WinRM Port**: WinRM port (Default: 5985/5986 for http/https). It can be overwriting at node level using `winrm-port` diff --git a/build.gradle b/build.gradle index 5e5f4b0..f9ef752 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } ext.pluginName = 'Python Winrm Node Executor/File Copier Plugin' -ext.pluginDescription = "Sincronize Azure Storage with a folder on remote nodes" +ext.pluginDescription = "Connect to remote windows nodes using WINRM" ext.sopsCopyright = "© 2017, Rundeck, Inc." ext.sopsUrl = "http://rundeck.com" ext.buildDateString=new Date().format("yyyy-MM-dd'T'HH:mm:ssX") @@ -16,7 +16,7 @@ ext.archivesBaseName = "py-winrm-plugin" ext.pluginBaseFolder = "." scmVersion { - ignoreUncommittedChanges = true + ignoreUncommittedChanges = false tag { prefix = '' versionSeparator = '' diff --git a/contents/__init__.py b/contents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/contents/common.py b/contents/common.py new file mode 100644 index 0000000..ae69859 --- /dev/null +++ b/contents/common.py @@ -0,0 +1,27 @@ +import re + + +def check_is_file(destination): + # check if destination file is a file + regex = r"((?:(?:[cC]:))[^\.]+\.[A-Za-z]{3})" + + matches = re.finditer(regex, destination, re.MULTILINE) + isfile = False + + for matchNum, match in enumerate(matches): + isfile = True + + return isfile + + +def get_file(destination): + filename = "" + split = "/" + if("\\" in destination): + split = "\\" + + for file in destination.split(split): + filename = file + + return filename + diff --git a/contents/protocol.py b/contents/protocol.py new file mode 100644 index 0000000..f4492e8 --- /dev/null +++ b/contents/protocol.py @@ -0,0 +1,83 @@ +import xml.etree.ElementTree as ET +import xmltodict +import base64 + +from winrm.exceptions import WinRMError, WinRMTransportError, WinRMOperationTimeoutError + +# TODO: this PR https://github.com/diyan/pywinrm/pull/55 will add this fix. +# when this PR is merged, this won't be needed anymore + + +def get_command_output(protocol, shell_id, command_id, out_stream=None, err_stream=None): + """ + Get the Output of the given shell and command + @param string shell_id: The shell id on the remote machine. + See #open_shell + @param string command_id: The command id on the remote machine. + See #run_command + @param stream out_stream: The stream of which the std_out would be directed to. (optional) + @param stream err_stream: The stream of which the std_err would be directed to. (optional) + #@return [Hash] Returns a Hash with a key :exitcode and :data. + Data is an Array of Hashes where the cooresponding key + # is either :stdout or :stderr. The reason it is in an Array so so + we can get the output in the order it ocurrs on + # the console. + """ + stdout_buffer, stderr_buffer = [], [] + command_done = False + while not command_done: + try: + stdout, stderr, return_code, command_done = \ + _raw_get_command_output(protocol, shell_id, command_id, out_stream, err_stream) + + stdout_buffer.append(stdout) + stderr_buffer.append(stderr) + except WinRMOperationTimeoutError as e: + # this is an expected error when waiting for a long-running process, just silently retry + pass + return b''.join(stdout_buffer), b''.join(stderr_buffer), return_code + + +def _raw_get_command_output(protocol,shell_id, command_id, out_stream=None, err_stream=None): + req = {'env:Envelope': protocol._get_soap_header( + resource_uri='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd', # NOQA + action='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Receive', # NOQA + shell_id=shell_id)} + + stream = req['env:Envelope'].setdefault('env:Body', {}).setdefault( + 'rsp:Receive', {}).setdefault('rsp:DesiredStream', {}) + stream['@CommandId'] = command_id + stream['#text'] = 'stdout stderr' + + res = protocol.send_message(xmltodict.unparse(req)) + root = ET.fromstring(res) + stream_nodes = [ + node for node in root.findall('.//*') + if node.tag.endswith('Stream')] + stdout = stderr = b'' + return_code = -1 + for stream_node in stream_nodes: + if not stream_node.text: + continue + + content = str(base64.b64decode(stream_node.text.encode('ascii'))) + + if stream_node.attrib['Name'] == 'stdout': + if out_stream: + out_stream.write(content) + stdout += content + elif stream_node.attrib['Name'] == 'stderr': + if err_stream: + err_stream.write(content) + stderr += content + + command_done = len([ + node for node in root.findall('.//*') + if node.get('State', '').endswith('CommandState/Done')]) == 1 + if command_done: + return_code = int( + next(node for node in root.findall('.//*') + if node.tag.endswith('ExitCode')).text) + + return stdout, stderr, return_code, command_done + diff --git a/contents/winrm-exec.py b/contents/winrm-exec.py index 171228b..49af3ed 100644 --- a/contents/winrm-exec.py +++ b/contents/winrm-exec.py @@ -1,10 +1,11 @@ -import winrm import argparse import os import sys import requests.packages.urllib3 +import winrm_session +import threading + requests.packages.urllib3.disable_warnings() -from winrm.protocol import Protocol parser = argparse.ArgumentParser(description='Run Bolt command.') parser.add_argument('hostname', help='the hostname') @@ -19,9 +20,6 @@ shell = "cmd" certpath = None -if "RD_CONFIG_PASSWORD_STORAGE_PATH" in os.environ: - password = os.getenv("RD_CONFIG_PASSWORD_STORAGE_PATH") - if "RD_CONFIG_AUTHTYPE" in os.environ: authentication = os.getenv("RD_CONFIG_AUTHTYPE") @@ -62,13 +60,20 @@ if "RD_CONFIG_USERNAME" in os.environ and os.getenv("RD_CONFIG_USERNAME"): username = os.getenv("RD_CONFIG_USERNAME").strip('\'') -if(debug): - print "------------------------------------------" - print "endpoint:" +endpoint - print "authentication:" +authentication - print "username:" +username - print "nossl:" + str(nossl) - print "------------------------------------------" +if "RD_OPTION_WINRMPASSWORD" in os.environ and os.getenv("RD_OPTION_WINRMPASSWORD"): + #take password from job + password = os.getenv("RD_OPTION_WINRMPASSWORD").strip('\'') +else: + if "RD_CONFIG_PASSWORD_STORAGE_PATH" in os.environ: + password = os.getenv("RD_CONFIG_PASSWORD_STORAGE_PATH") + +if debug: + print("------------------------------------------") + print("endpoint:" + endpoint) + print("authentication:" + authentication) + print("username:" + username) + print("nossl:" + str(nossl)) + print("------------------------------------------") arguments = {} arguments["transport"] = authentication @@ -76,23 +81,48 @@ if(nossl == True): arguments["server_cert_validation"] = "ignore" else: - if(transport=="https"): + if(transport == "https"): arguments["server_cert_validation"] = "validate" arguments["ca_trust_path"] = certpath -session = winrm.Session(target=endpoint, +session = winrm_session.Session(target=endpoint, auth=(username, password), **arguments) -if shell == "cmd": - result = session.run_cmd(exec_command) - -if shell == "powershell": - result = session.run_ps(exec_command) - -print result.std_out - -if(result.std_err): - print result.std_err - -sys.exit(result.status_code) +tsk = winrm_session.RunCommand(session, shell, exec_command) +t = threading.Thread(target=tsk.get_response) +t.start() +realstdout = sys.stdout +realstderr = sys.stderr +sys.stdout = tsk.o_stream +sys.stderr = tsk.e_stream + +lastpos = 0 +lasterrorpos = 0 + +while True: + t.join(.1) + + if sys.stdout.tell() != lastpos: + sys.stdout.seek(lastpos) + realstdout.write(sys.stdout.read()) + lastpos = sys.stdout.tell() + + if sys.stderr.tell() != lasterrorpos: + sys.stderr.seek(lasterrorpos) + realstderr.write(session._clean_error_msg(sys.stderr.read())) + lasterrorpos = sys.stderr.tell() + + if not t.is_alive(): + break + +sys.stdout.seek(0) +sys.stderr.seek(0) +sys.stdout = realstdout +sys.stderr = realstderr + +if tsk.e_std: + sys.stderr.write(tsk.e_std) + sys.exit(1) +else: + sys.exit(tsk.stat) \ No newline at end of file diff --git a/contents/winrm-filecopier.py b/contents/winrm-filecopier.py index 11bad84..912d75d 100644 --- a/contents/winrm-filecopier.py +++ b/contents/winrm-filecopier.py @@ -4,11 +4,24 @@ import sys import base64 import time -from base64 import b64encode -from winrm.protocol import Protocol +import common import requests.packages.urllib3 +import logging + requests.packages.urllib3.disable_warnings() +if os.environ.get('RD_CONFIG_DEBUG') == 'true': + log_level = 'DEBUG' +else: + log_level = 'ERROR' + +logging.basicConfig( + stream=sys.stdout, + level=getattr(logging, log_level), + format='%(levelname)s: %(name)s: %(message)s' +) +log = logging.getLogger('winrm-filecopier') + class RemoteCommandError(Exception): def __init__(self, command, return_code, std_out='', std_err=''): @@ -29,19 +42,24 @@ class CopyFiles(object): def __init__(self, session): self.session=session - def winrm_upload( - self, - remote_path, - local_path, - step=1024, - winrm_kwargs=dict(), - quiet=False, - **kwargs - ): - - self.session.run_ps('if (Test-Path {0}) {{ Remove-Item {0} }}'.format(remote_path)) + + def winrm_upload(self, + remote_path, + remote_filename, + local_path, + step=2048, + quiet=True): + + if remote_path.endswith('/') or remote_path.endswith('\\'): + full_path = remote_path + remote_filename + else: + full_path = remote_path + "\\" + remote_filename + + print("coping file %s to %s" % (local_path, full_path)) + + self.session.run_ps('if (!(Test-Path {0})) {{ New-Item -ItemType directory -Path {0} }}'.format(remote_path)) + size = os.stat(local_path).st_size - start = time.time() with open(local_path, 'rb') as f: for i in range(0, size, step): script = ( @@ -49,7 +67,7 @@ def winrm_upload( '$([System.Convert]::FromBase64String("{}")) ' '-encoding byte -path {}'.format( base64.b64encode(f.read(step)), - remote_path + full_path ) ) while True: @@ -61,7 +79,6 @@ def winrm_upload( if code == 0: break elif code == 1 and 'used by another process' in stderr: - # Small delay so previous write can settle down time.sleep(0.1) else: raise WinRmError(script, code, stdout, stderr) @@ -74,9 +91,10 @@ def winrm_upload( (100 * transferred) // size ) + ' %' percentage_string = ( - ' ' * (5 - len(percentage_string)) + + ' ' * (10 - len(percentage_string)) + percentage_string ) + print(percentage_string) sys.stdout.flush() @@ -84,6 +102,7 @@ def winrm_upload( parser.add_argument('hostname', help='the hostname') parser.add_argument('source', help='Source File') parser.add_argument('destination', help='Destination File') + args = parser.parse_args() #it is necesarry to avoid the debug error @@ -96,9 +115,6 @@ def winrm_upload( nossl = False debug = False -if "RD_CONFIG_PASSWORD_STORAGE_PATH" in os.environ: - password = os.getenv("RD_CONFIG_PASSWORD_STORAGE_PATH") - if "RD_CONFIG_AUTHTYPE" in os.environ: authentication = os.getenv("RD_CONFIG_AUTHTYPE") @@ -129,6 +145,17 @@ def winrm_upload( if "RD_CONFIG_USERNAME" in os.environ and os.getenv("RD_CONFIG_USERNAME"): username = os.getenv("RD_CONFIG_USERNAME").strip('\'') +if "RD_OPTION_WINRMPASSWORD" in os.environ and os.getenv("RD_OPTION_WINRMPASSWORD"): + #take password from job + password = os.getenv("RD_OPTION_WINRMPASSWORD").strip('\'') +else: + if "RD_CONFIG_PASSWORD_STORAGE_PATH" in os.environ: + password = os.getenv("RD_CONFIG_PASSWORD_STORAGE_PATH") + +quiet = True +if "RD_CONFIG_DEBUG" in os.environ: + quiet = False + endpoint = transport+'://'+args.hostname+':'+port arguments = {} @@ -146,4 +173,25 @@ def winrm_upload( **arguments) copy = CopyFiles(session) -copy.winrm_upload(args.destination,args.source) + +destination = args.destination +filename = os.path.basename(args.source) + +if filename in args.destination: + destination = destination.replace(filename, '') +else: + isFile = common.check_is_file(args.destination) + if isFile: + filename = common.get_file(args.destination) + destination = destination.replace(filename, '') + else: + filename = os.path.basename(args.source) + +if not os.path.isdir(args.source): + copy.winrm_upload(remote_path=destination, + remote_filename=filename, + local_path=args.source, + quiet=quiet) +else: + log.warn("The source is a directory, skipping copy") + diff --git a/contents/winrm_session.py b/contents/winrm_session.py new file mode 100644 index 0000000..4b65bfb --- /dev/null +++ b/contents/winrm_session.py @@ -0,0 +1,146 @@ +from __future__ import unicode_literals +import re +from base64 import b64encode +import xml.etree.ElementTree as ET +from StringIO import StringIO + +from winrm.protocol import Protocol + +# TODO: this PR https://github.com/diyan/pywinrm/pull/55 will add this fix. +# when this PR is merged, this won't be needed anymore + +# Feature support attributes for multi-version clients. +# These values can be easily checked for with hasattr(winrm, "FEATURE_X"), +# "'auth_type' in winrm.FEATURE_SUPPORTED_AUTHTYPES", etc for clients to sniff features +# supported by a particular version of pywinrm +FEATURE_SUPPORTED_AUTHTYPES=['basic', 'certificate', 'ntlm', 'kerberos', 'plaintext', 'ssl', 'credssp'] +FEATURE_READ_TIMEOUT=True +FEATURE_OPERATION_TIMEOUT=True + +import protocol + +class Response(object): + """Response from a remote command execution""" + def __init__(self, args): + self.std_out, self.std_err, self.status_code = args + + def __repr__(self): + # TODO put tree dots at the end if out/err was truncated + return ''.format( + self.status_code, self.std_out[:20], self.std_err[:20]) + + +class Session(object): + # TODO implement context manager methods + def __init__(self, target, auth, **kwargs): + username, password = auth + self.url = self._build_url(target, kwargs.get('transport', 'plaintext')) + self.protocol = Protocol(self.url, + username=username, password=password, **kwargs) + + def run_cmd(self, command, args=(), out_stream=None, err_stream=None): + + self.protocol.get_command_output = protocol.get_command_output + + # TODO optimize perf. Do not call open/close shell every time + shell_id = self.protocol.open_shell() + command_id = self.protocol.run_command(shell_id, command, args) + rs = Response(self.protocol.get_command_output(self.protocol, shell_id, command_id, out_stream, err_stream)) + + error = self._clean_error_msg(rs.std_err) + rs.std_err = error + + self.protocol.cleanup_command(shell_id, command_id) + self.protocol.close_shell(shell_id) + return rs + + def run_ps(self, script, out_stream=None, err_stream=None): + """base64 encodes a Powershell script and executes the powershell + encoded script command + """ + # must use utf16 little endian on windows + encoded_ps = b64encode(script.encode('utf_16_le')).decode('ascii') + rs = self.run_cmd('powershell -encodedcommand {0}'.format(encoded_ps),out_stream=out_stream, err_stream=err_stream) + + #print "run_ps=>err_stream: %s" % err_stream.getvalue() + + error = self._clean_error_msg(rs.std_err) + rs.std_err = error + return rs + + def _clean_error_msg(self, msg): + if msg.startswith(b"#< CLIXML\r\n"): + msg_xml = msg[11:] + try: + # remove the namespaces from the xml for easier processing + msg_xml = self._strip_namespace(msg_xml) + root = ET.fromstring(msg_xml) + # the S node is the error message, find all S nodes + nodes = root.findall("./S") + new_msg = "" + for s in nodes: + # append error msg string to result, also + # the hex chars represent CRLF so we replace with newline + new_msg += s.text.replace("_x000D__x000A_", "\n") + except Exception as e: + # if any of the above fails, the msg was not true xml + # print a warning and return the orignal string + print("Warning: there was a problem converting the Powershell" + " error message: %s" % (e)) + else: + # if new_msg was populated, that's our error message + # otherwise the original error message will be used + if len(new_msg): + # remove leading and trailing whitespace while we are here + msg = new_msg.strip() + return new_msg + else: + return msg + + def _strip_namespace(self, xml): + """strips any namespaces from an xml string""" + p = re.compile(b"xmlns=*[\"\"][^\"\"]*[\"\"]") + allmatches = p.finditer(xml) + for match in allmatches: + xml = xml.replace(match.group(), b"") + return xml + + @staticmethod + def _build_url(target, transport): + match = re.match( + '(?i)^((?Phttp[s]?)://)?(?P[0-9a-z-_.]+)(:(?P\d+))?(?P(/)?(wsman)?)?', target) # NOQA + scheme = match.group('scheme') + if not scheme: + # TODO do we have anything other than HTTP/HTTPS + scheme = 'https' if transport == 'ssl' else 'http' + host = match.group('host') + port = match.group('port') + if not port: + port = 5986 if transport == 'ssl' else 5985 + path = match.group('path') + if not path: + path = 'wsman' + return '{0}://{1}:{2}/{3}'.format(scheme, host, port, path.lstrip('/')) + + +class RunCommand: + def __init__(self, session, shell, command ): + self.stat, self.o_std, self.e_std = None, None, None + self.o_stream = StringIO() + self.e_stream = StringIO() + self.session = session + self.exec_command = command + self.shell = shell + + def get_response(self): + if self.shell == "cmd": + response = self.session.run_cmd(self.exec_command, out_stream=self.o_stream, err_stream=self.e_stream) + self.o_std = response.std_out + self.e_std = response.std_err + self.stat = response.status_code + + if self.shell == "powershell": + response = self.session.run_ps(self.exec_command, out_stream=self.o_stream, err_stream=self.e_stream) + self.o_std = response.std_out + self.e_std = response.std_err + self.stat = response.status_code diff --git a/plugin.yaml b/plugin.yaml index 920a9f1..71e4853 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,8 +1,9 @@ name: py-winrm-plugin -version: 1.0.0 rundeckPluginVersion: 1.2 -author: Luis Toledo -date: Tue Nov 28 2017 +author: "@author@" +date: "@date@" +version: "@version@" +url: "@url@" providers: - name: WinRMPython title: WinRM Node Executor Python @@ -177,6 +178,10 @@ providers: valueConversion: "STORAGE_PATH_AUTOMATIC_READ" storage-path-root: "keys" instance-scope-node-attribute: "winrm-password-storage-path" + - type: Boolean + name: debug + title: Debug? + description: 'Write debug messages' - name: WinRMCheck title: WinRM Check Step description: Check the connection with a remote node using winrm-python diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000..fa549dd Binary files /dev/null and b/resources/icon.png differ