# encoding: UTF-8 # # Copyright 2010 Dan Wanek # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. require 'nori' require 'rexml/document' require 'securerandom' require_relative 'command_executor' require_relative 'command_output_decoder' require_relative 'helpers/powershell_script' module WinRM # This is the main class that does the SOAP request/response logic. There are a few helper # classes, but pretty much everything comes through here first. class WinRMWebService DEFAULT_TIMEOUT = 'PT60S' DEFAULT_MAX_ENV_SIZE = 153600 DEFAULT_LOCALE = 'en-US' attr_reader :endpoint, :timeout, :retry_limit, :retry_delay, :output_decoder attr_accessor :logger # @param [String,URI] endpoint the WinRM webservice endpoint # @param [Symbol] transport either :kerberos(default)/:ssl/:plaintext # @param [Hash] opts Misc opts for the various transports. # @see WinRM::HTTP::HttpTransport # @see WinRM::HTTP::HttpGSSAPI # @see WinRM::HTTP::HttpNegotiate # @see WinRM::HTTP::HttpSSL def initialize(endpoint, transport = :kerberos, opts = {}) @endpoint = endpoint @timeout = DEFAULT_TIMEOUT @max_env_sz = DEFAULT_MAX_ENV_SIZE @locale = DEFAULT_LOCALE @output_decoder = CommandOutputDecoder.new setup_logger configure_retries(opts) begin @xfer = send "init_#{transport}_transport", opts.merge({endpoint: endpoint}) rescue NoMethodError => e raise "Invalid transport '#{transport}' specified, expected: negotiate, kerberos, plaintext, ssl." end end def init_negotiate_transport(opts) HTTP::HttpNegotiate.new(opts[:endpoint], opts[:user], opts[:pass], opts) end def init_kerberos_transport(opts) require 'gssapi' require 'gssapi/extensions' HTTP::HttpGSSAPI.new(opts[:endpoint], opts[:realm], opts[:service], opts[:keytab], opts) end def init_plaintext_transport(opts) HTTP::HttpPlaintext.new(opts[:endpoint], opts[:user], opts[:pass], opts) end def init_ssl_transport(opts) if opts[:basic_auth_only] HTTP::BasicAuthSSL.new(opts[:endpoint], opts[:user], opts[:pass], opts) else HTTP::HttpNegotiate.new(opts[:endpoint], opts[:user], opts[:pass], opts) end end # Operation timeout. # # Unless specified the client receive timeout will be 10s + the operation # timeout. # # @see http://msdn.microsoft.com/en-us/library/ee916629(v=PROT.13).aspx # # @param [Fixnum] The number of seconds to set the WinRM operation timeout # @param [Fixnum] The number of seconds to set the Ruby receive timeout # @return [String] The ISO 8601 formatted operation timeout def set_timeout(op_timeout_sec, receive_timeout_sec=nil) @timeout = Iso8601Duration.sec_to_dur(op_timeout_sec) @xfer.receive_timeout = receive_timeout_sec || op_timeout_sec + 10 @timeout end alias :op_timeout :set_timeout # Max envelope size # @see http://msdn.microsoft.com/en-us/library/ee916127(v=PROT.13).aspx # @param [Fixnum] byte_sz the max size in bytes to allow for the response def max_env_size(byte_sz) @max_env_sz = byte_sz end # Set the locale # @see http://msdn.microsoft.com/en-us/library/gg567404(v=PROT.13).aspx # @param [String] locale the locale to set for future messages def locale(locale) @locale = locale end # Create a Shell on the destination host # @param [Hash] shell_opts additional shell options you can pass # @option shell_opts [String] :i_stream Which input stream to open. Leave this alone unless you know what you're doing (default: stdin) # @option shell_opts [String] :o_stream Which output stream to open. Leave this alone unless you know what you're doing (default: stdout stderr) # @option shell_opts [String] :working_directory the directory to create the shell in # @option shell_opts [Hash] :env_vars environment variables to set for the shell. For instance; # :env_vars => {:myvar1 => 'val1', :myvar2 => 'var2'} # @return [String] The ShellId from the SOAP response. This is our open shell instance on the remote machine. def open_shell(shell_opts = {}, &block) logger.debug("[WinRM] opening remote shell on #{@endpoint}") i_stream = shell_opts.has_key?(:i_stream) ? shell_opts[:i_stream] : 'stdin' o_stream = shell_opts.has_key?(:o_stream) ? shell_opts[:o_stream] : 'stdout stderr' codepage = shell_opts.has_key?(:codepage) ? shell_opts[:codepage] : 65001 # utf8 as default codepage (from https://msdn.microsoft.com/en-us/library/dd317756(VS.85).aspx) noprofile = shell_opts.has_key?(:noprofile) ? shell_opts[:noprofile] : 'FALSE' h_opts = { "#{NS_WSMAN_DMTF}:OptionSet" => { "#{NS_WSMAN_DMTF}:Option" => [noprofile, codepage], :attributes! => {"#{NS_WSMAN_DMTF}:Option" => {'Name' => ['WINRS_NOPROFILE','WINRS_CODEPAGE']}}}} shell_body = { "#{NS_WIN_SHELL}:InputStreams" => i_stream, "#{NS_WIN_SHELL}:OutputStreams" => o_stream } shell_body["#{NS_WIN_SHELL}:WorkingDirectory"] = shell_opts[:working_directory] if shell_opts.has_key?(:working_directory) shell_body["#{NS_WIN_SHELL}:IdleTimeOut"] = shell_opts[:idle_timeout] if(shell_opts.has_key?(:idle_timeout) && shell_opts[:idle_timeout].is_a?(String)) if(shell_opts.has_key?(:env_vars) && shell_opts[:env_vars].is_a?(Hash)) keys = shell_opts[:env_vars].keys vals = shell_opts[:env_vars].values shell_body["#{NS_WIN_SHELL}:Environment"] = { "#{NS_WIN_SHELL}:Variable" => vals, :attributes! => {"#{NS_WIN_SHELL}:Variable" => {'Name' => keys}} } end builder = Builder::XmlMarkup.new builder.instruct!(:xml, :encoding => 'UTF-8') builder.tag! :env, :Envelope, namespaces do |env| env.tag!(:env, :Header) { |h| h << Gyoku.xml(merge_headers(header,resource_uri_cmd,action_create,h_opts)) } env.tag! :env, :Body do |body| body.tag!("#{NS_WIN_SHELL}:Shell") { |s| s << Gyoku.xml(shell_body)} end end resp_doc = send_message(builder.target!) shell_id = REXML::XPath.first(resp_doc, "//*[@Name='ShellId']").text logger.debug("[WinRM] remote shell #{shell_id} is open on #{@endpoint}") if block_given? begin yield shell_id ensure close_shell(shell_id) end else shell_id end end # Run a command on a machine with an open shell # @param [String] shell_id The shell id on the remote machine. See #open_shell # @param [String] command The command to run on the remote machine # @param [Array] arguments An array of arguments for this command # @return [String] The CommandId from the SOAP response. This is the ID we need to query in order to get output. def run_command(shell_id, command, arguments = [], cmd_opts = {}, &block) consolemode = cmd_opts.has_key?(:console_mode_stdin) ? cmd_opts[:console_mode_stdin] : 'TRUE' skipcmd = cmd_opts.has_key?(:skip_cmd_shell) ? cmd_opts[:skip_cmd_shell] : 'FALSE' h_opts = { "#{NS_WSMAN_DMTF}:OptionSet" => { "#{NS_WSMAN_DMTF}:Option" => [consolemode, skipcmd], :attributes! => {"#{NS_WSMAN_DMTF}:Option" => {'Name' => ['WINRS_CONSOLEMODE_STDIN','WINRS_SKIP_CMD_SHELL']}}} } body = { "#{NS_WIN_SHELL}:Command" => "\"#{command}\"", "#{NS_WIN_SHELL}:Arguments" => arguments} builder = Builder::XmlMarkup.new builder.instruct!(:xml, :encoding => 'UTF-8') builder.tag! :env, :Envelope, namespaces do |env| env.tag!(:env, :Header) { |h| h << Gyoku.xml(merge_headers(header,resource_uri_cmd,action_command,h_opts,selector_shell_id(shell_id))) } env.tag!(:env, :Body) do |env_body| env_body.tag!("#{NS_WIN_SHELL}:CommandLine") { |cl| cl << Gyoku.xml(body) } end end # Grab the command element and unescape any single quotes - issue 69 xml = builder.target! escaped_cmd = /<#{NS_WIN_SHELL}:Command>(.+)<\/#{NS_WIN_SHELL}:Command>/m.match(xml)[1] xml[escaped_cmd] = escaped_cmd.gsub(/'/, "'") resp_doc = send_message(xml) command_id = REXML::XPath.first(resp_doc, "//#{NS_WIN_SHELL}:CommandId").text if block_given? begin yield command_id ensure cleanup_command(shell_id, command_id) end else command_id end end def write_stdin(shell_id, command_id, stdin) # Signal the Command references to terminate (close stdout/stderr) body = { "#{NS_WIN_SHELL}:Send" => { "#{NS_WIN_SHELL}:Stream" => { "@Name" => 'stdin', "@CommandId" => command_id, :content! => Base64.encode64(stdin) } } } builder = Builder::XmlMarkup.new builder.instruct!(:xml, :encoding => 'UTF-8') builder.tag! :env, :Envelope, namespaces do |env| env.tag!(:env, :Header) { |h| h << Gyoku.xml(merge_headers(header,resource_uri_cmd,action_send,selector_shell_id(shell_id))) } env.tag!(:env, :Body) do |env_body| env_body << Gyoku.xml(body) end end resp = send_message(builder.target!) true end # 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 # @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. def get_command_output(shell_id, command_id, &block) body = { "#{NS_WIN_SHELL}:DesiredStream" => 'stdout stderr', :attributes! => {"#{NS_WIN_SHELL}:DesiredStream" => {'CommandId' => command_id}}} builder = Builder::XmlMarkup.new builder.instruct!(:xml, :encoding => 'UTF-8') builder.tag! :env, :Envelope, namespaces do |env| env.tag!(:env, :Header) { |h| h << Gyoku.xml(merge_headers(header,resource_uri_cmd,action_receive,selector_shell_id(shell_id))) } env.tag!(:env, :Body) do |env_body| env_body.tag!("#{NS_WIN_SHELL}:Receive") { |cl| cl << Gyoku.xml(body) } end end resp_doc = nil request_msg = builder.target! done_elems = [] output = Output.new while done_elems.empty? resp_doc = send_get_output_message(request_msg) REXML::XPath.match(resp_doc, "//#{NS_WIN_SHELL}:Stream").each do |n| next if n.text.nil? || n.text.empty? decoded_text = output_decoder.decode(n.text) stream = { n.attributes['Name'].to_sym => decoded_text } output[:data] << stream yield stream[:stdout], stream[:stderr] if block_given? end # We may need to get additional output if the stream has not finished. # The CommandState will change from Running to Done like so: # @example # from... # # to... # # 0 # done_elems = REXML::XPath.match(resp_doc, "//*[@State='http://schemas.microsoft.com/wbem/wsman/1/windows/shell/CommandState/Done']") end output[:exitcode] = REXML::XPath.first(resp_doc, "//#{NS_WIN_SHELL}:ExitCode").text.to_i output end # Clean-up after a command. # @see #run_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 # @return [true] This should have more error checking but it just returns true for now. def cleanup_command(shell_id, command_id) # Signal the Command references to terminate (close stdout/stderr) body = { "#{NS_WIN_SHELL}:Code" => 'http://schemas.microsoft.com/wbem/wsman/1/windows/shell/signal/terminate' } builder = Builder::XmlMarkup.new builder.instruct!(:xml, :encoding => 'UTF-8') builder.tag! :env, :Envelope, namespaces do |env| env.tag!(:env, :Header) { |h| h << Gyoku.xml(merge_headers(header,resource_uri_cmd,action_signal,selector_shell_id(shell_id))) } env.tag!(:env, :Body) do |env_body| env_body.tag!("#{NS_WIN_SHELL}:Signal", {'CommandId' => command_id}) { |cl| cl << Gyoku.xml(body) } end end resp = send_message(builder.target!) true end # Close the shell # @param [String] shell_id The shell id on the remote machine. See #open_shell # @return [true] This should have more error checking but it just returns true for now. def close_shell(shell_id) logger.debug("[WinRM] closing remote shell #{shell_id} on #{@endpoint}") builder = Builder::XmlMarkup.new builder.instruct!(:xml, :encoding => 'UTF-8') builder.tag!('env:Envelope', namespaces) do |env| env.tag!('env:Header') { |h| h << Gyoku.xml(merge_headers(header,resource_uri_cmd,action_delete,selector_shell_id(shell_id))) } env.tag!('env:Body') end resp = send_message(builder.target!) logger.debug("[WinRM] remote shell #{shell_id} closed") true end # DEPRECATED: Use WinRM::CommandExecutor#run_cmd instead # Run a CMD command # @param [String] command The command to run on the remote system # @param [Array ] arguments arguments to the command # @param [String] an existing and open shell id to reuse # @return [Hash] :stdout and :stderr def run_cmd(command, arguments = [], &block) logger.warn("WinRM::WinRMWebService#run_cmd is deprecated. Use WinRM::CommandExecutor#run_cmd instead") create_executor do |executor| executor.run_cmd(command, arguments, &block) end end alias :cmd :run_cmd # DEPRECATED: Use WinRM::CommandExecutor#run_powershell_script instead # Run a Powershell script that resides on the local box. # @param [IO,String] script_file an IO reference for reading the Powershell script or the actual file contents # @param [String] an existing and open shell id to reuse # @return [Hash] :stdout and :stderr def run_powershell_script(script_file, &block) logger.warn("WinRM::WinRMWebService#run_powershell_script is deprecated. Use WinRM::CommandExecutor#run_powershell_script instead") create_executor do |executor| executor.run_powershell_script(script_file, &block) end end alias :powershell :run_powershell_script # Creates a CommandExecutor initialized with this WinRMWebService # If called with a block, create_executor yields an executor and # ensures that the executor is closed after the block completes. # The CommandExecutor is simply returned if no block is given. # @yieldparam [CommandExecutor] a CommandExecutor instance # @return [CommandExecutor] a CommandExecutor instance def create_executor(&block) executor = CommandExecutor.new(self) executor.open if block_given? begin yield executor ensure executor.close end else executor end end # Run a WQL Query # @see http://msdn.microsoft.com/en-us/library/aa394606(VS.85).aspx # @param [String] wql The WQL query # @return [Hash] Returns a Hash that contain the key/value pairs returned from the query. def run_wql(wql) body = { "#{NS_WSMAN_DMTF}:OptimizeEnumeration" => nil, "#{NS_WSMAN_DMTF}:MaxElements" => '32000', "#{NS_WSMAN_DMTF}:Filter" => wql, :attributes! => { "#{NS_WSMAN_DMTF}:Filter" => {'Dialect' => 'http://schemas.microsoft.com/wbem/wsman/1/WQL'}} } builder = Builder::XmlMarkup.new builder.instruct!(:xml, :encoding => 'UTF-8') builder.tag! :env, :Envelope, namespaces do |env| env.tag!(:env, :Header) { |h| h << Gyoku.xml(merge_headers(header,resource_uri_wmi,action_enumerate)) } env.tag!(:env, :Body) do |env_body| env_body.tag!("#{NS_ENUM}:Enumerate") { |en| en << Gyoku.xml(body) } end end resp = send_message(builder.target!) parser = Nori.new(:parser => :rexml, :advanced_typecasting => false, :convert_tags_to => lambda { |tag| tag.snakecase.to_sym }, :strip_namespaces => true) hresp = parser.parse(resp.to_s)[:envelope][:body] # Normalize items so the type always has an array even if it's just a single item. items = {} if hresp[:enumerate_response][:items] hresp[:enumerate_response][:items].each_pair do |k,v| if v.is_a?(Array) items[k] = v else items[k] = [v] end end end items end alias :wql :run_wql def toggle_nori_type_casting(to) logger.warn('toggle_nori_type_casting is deprecated and has no effect, ' + 'please remove calls to it') end private def setup_logger @logger = Logging.logger[self] @logger.level = :warn @logger.add_appenders(Logging.appenders.stdout) end def configure_retries(opts) @retry_delay = opts[:retry_delay] || 10 @retry_limit = opts[:retry_limit] || 3 end def namespaces { 'xmlns:xsd' => 'http://www.w3.org/2001/XMLSchema', 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', 'xmlns:env' => 'http://www.w3.org/2003/05/soap-envelope', 'xmlns:a' => 'http://schemas.xmlsoap.org/ws/2004/08/addressing', 'xmlns:b' => 'http://schemas.dmtf.org/wbem/wsman/1/cimbinding.xsd', 'xmlns:n' => 'http://schemas.xmlsoap.org/ws/2004/09/enumeration', 'xmlns:x' => 'http://schemas.xmlsoap.org/ws/2004/09/transfer', 'xmlns:w' => 'http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd', 'xmlns:p' => 'http://schemas.microsoft.com/wbem/wsman/1/wsman.xsd', 'xmlns:rsp' => 'http://schemas.microsoft.com/wbem/wsman/1/windows/shell', 'xmlns:cfg' => 'http://schemas.microsoft.com/wbem/wsman/1/config', } end def header { "#{NS_ADDRESSING}:To" => "#{@xfer.endpoint.to_s}", "#{NS_ADDRESSING}:ReplyTo" => { "#{NS_ADDRESSING}:Address" => 'http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous', :attributes! => {"#{NS_ADDRESSING}:Address" => {'mustUnderstand' => true}}}, "#{NS_WSMAN_DMTF}:MaxEnvelopeSize" => @max_env_sz, "#{NS_ADDRESSING}:MessageID" => "uuid:#{SecureRandom.uuid.to_s.upcase}", "#{NS_WSMAN_DMTF}:Locale/" => '', "#{NS_WSMAN_MSFT}:DataLocale/" => '', "#{NS_WSMAN_DMTF}:OperationTimeout" => @timeout, :attributes! => { "#{NS_WSMAN_DMTF}:MaxEnvelopeSize" => {'mustUnderstand' => true}, "#{NS_WSMAN_DMTF}:Locale/" => {'xml:lang' => @locale, 'mustUnderstand' => false}, "#{NS_WSMAN_MSFT}:DataLocale/" => {'xml:lang' => @locale, 'mustUnderstand' => false} }} end # merge the various header hashes and make sure we carry all of the attributes # through instead of overwriting them. def merge_headers(*headers) hdr = {} headers.each do |h| hdr.merge!(h) do |k,v1,v2| v1.merge!(v2) if k == :attributes! end end hdr end def send_get_output_message(message) send_message(message) rescue WinRMWSManFault => e # If no output is available before the wsman:OperationTimeout expires, # the server MUST return a WSManFault with the Code attribute equal to # 2150858793. When the client receives this fault, it SHOULD issue # another Receive request. # http://msdn.microsoft.com/en-us/library/cc251676.aspx if e.fault_code == '2150858793' logger.debug("[WinRM] retrying receive request after timeout") retry else raise end end def send_message(message) @xfer.send_request(message) end # Helper methods for SOAP Headers def resource_uri_cmd {"#{NS_WSMAN_DMTF}:ResourceURI" => 'http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd', :attributes! => {"#{NS_WSMAN_DMTF}:ResourceURI" => {'mustUnderstand' => true}}} end def resource_uri_wmi(namespace = 'root/cimv2/*') {"#{NS_WSMAN_DMTF}:ResourceURI" => "http://schemas.microsoft.com/wbem/wsman/1/wmi/#{namespace}", :attributes! => {"#{NS_WSMAN_DMTF}:ResourceURI" => {'mustUnderstand' => true}}} end def action_create {"#{NS_ADDRESSING}:Action" => 'http://schemas.xmlsoap.org/ws/2004/09/transfer/Create', :attributes! => {"#{NS_ADDRESSING}:Action" => {'mustUnderstand' => true}}} end def action_delete {"#{NS_ADDRESSING}:Action" => 'http://schemas.xmlsoap.org/ws/2004/09/transfer/Delete', :attributes! => {"#{NS_ADDRESSING}:Action" => {'mustUnderstand' => true}}} end def action_command {"#{NS_ADDRESSING}:Action" => 'http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Command', :attributes! => {"#{NS_ADDRESSING}:Action" => {'mustUnderstand' => true}}} end def action_receive {"#{NS_ADDRESSING}:Action" => 'http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Receive', :attributes! => {"#{NS_ADDRESSING}:Action" => {'mustUnderstand' => true}}} end def action_signal {"#{NS_ADDRESSING}:Action" => 'http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Signal', :attributes! => {"#{NS_ADDRESSING}:Action" => {'mustUnderstand' => true}}} end def action_send {"#{NS_ADDRESSING}:Action" => 'http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Send', :attributes! => {"#{NS_ADDRESSING}:Action" => {'mustUnderstand' => true}}} end def action_enumerate {"#{NS_ADDRESSING}:Action" => 'http://schemas.xmlsoap.org/ws/2004/09/enumeration/Enumerate', :attributes! => {"#{NS_ADDRESSING}:Action" => {'mustUnderstand' => true}}} end def selector_shell_id(shell_id) {"#{NS_WSMAN_DMTF}:SelectorSet" => {"#{NS_WSMAN_DMTF}:Selector" => shell_id, :attributes! => {"#{NS_WSMAN_DMTF}:Selector" => {'Name' => 'ShellId'}}} } end end # WinRMWebService end # WinRM