#
# Author:: Adam Jacob (<adam@opscode.com>)
# Copyright:: Copyright (c) 2009 Opscode, Inc.
# License:: Apache License, Version 2.0
#
# 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 'chef/knife'

class Chef
  class Knife
    class Ssh < Knife

      deps do
        require 'net/ssh'
        require 'net/ssh/multi'
        require 'chef/monkey_patches/net-ssh-multi'
        require 'readline'
        require 'chef/exceptions'
        require 'chef/search/query'
        require 'chef/mixin/shell_out'
        require 'chef/mixin/command'
        require 'mixlib/shellout'
      end

      include Chef::Mixin::ShellOut

      attr_writer :password

      banner "knife ssh QUERY COMMAND (options)"

      option :concurrency,
        :short => "-C NUM",
        :long => "--concurrency NUM",
        :description => "The number of concurrent connections",
        :default => nil,
        :proc => lambda { |o| o.to_i }

      option :attribute,
        :short => "-a ATTR",
        :long => "--attribute ATTR",
        :description => "The attribute to use for opening the connection - default depends on the context",
        :proc => Proc.new { |key| Chef::Config[:knife][:ssh_attribute] = key.strip }

      option :manual,
        :short => "-m",
        :long => "--manual-list",
        :boolean => true,
        :description => "QUERY is a space separated list of servers",
        :default => false

      option :ssh_user,
        :short => "-x USERNAME",
        :long => "--ssh-user USERNAME",
        :description => "The ssh username"

      option :ssh_password_ng,
        :short => "-P [PASSWORD]",
        :long => "--ssh-password [PASSWORD]",
        :description => "The ssh password - will prompt if flag is specified but no password is given",
        # default to a value that can not be a password (boolean)
        # so we can effectively test if this parameter was specified
        # without a vlaue
        :default => false

      option :ssh_port,
        :short => "-p PORT",
        :long => "--ssh-port PORT",
        :description => "The ssh port",
        :proc => Proc.new { |key| Chef::Config[:knife][:ssh_port] = key.strip }

      option :ssh_gateway,
        :short => "-G GATEWAY",
        :long => "--ssh-gateway GATEWAY",
        :description => "The ssh gateway",
        :proc => Proc.new { |key| Chef::Config[:knife][:ssh_gateway] = key.strip }

      option :forward_agent,
        :short => "-A",
        :long => "--forward-agent",
        :description => "Enable SSH agent forwarding",
        :boolean => true

      option :identity_file,
        :short => "-i IDENTITY_FILE",
        :long => "--identity-file IDENTITY_FILE",
        :description => "The SSH identity file used for authentication"

      option :host_key_verify,
        :long => "--[no-]host-key-verify",
        :description => "Verify host key, enabled by default.",
        :boolean => true,
        :default => true

      def session
        config[:on_error] ||= :skip
        ssh_error_handler = Proc.new do |server|
          case config[:on_error]
          when :skip
            ui.warn "Failed to connect to #{server.host} -- #{$!.class.name}: #{$!.message}"
            $!.backtrace.each { |l| Chef::Log.debug(l) }
          when :raise
            #Net::SSH::Multi magic to force exception to be re-raised.
            throw :go, :raise
          end
        end

        @session ||= Net::SSH::Multi.start(:concurrent_connections => config[:concurrency], :on_error => ssh_error_handler)
      end

      def configure_gateway
        config[:ssh_gateway] ||= Chef::Config[:knife][:ssh_gateway]
        if config[:ssh_gateway]
          gw_host, gw_user = config[:ssh_gateway].split('@').reverse
          gw_host, gw_port = gw_host.split(':')
          gw_opts = gw_port ? { :port => gw_port } : {}

          session.via(gw_host, gw_user || config[:ssh_user], gw_opts)
        end
      rescue Net::SSH::AuthenticationFailed
        user = gw_user || config[:ssh_user]
        prompt = "Enter the password for #{user}@#{gw_host}: "
        gw_opts.merge!(:password => prompt_for_password(prompt))
        session.via(gw_host, user, gw_opts)
      end

      def configure_session
        list = config[:manual] ?
               @name_args[0].split(" ") :
               search_nodes
        if list.length == 0
          if @action_nodes.length == 0
            ui.fatal("No nodes returned from search!")
          else
            ui.fatal("#{@action_nodes.length} #{@action_nodes.length > 1 ? "nodes":"node"} found, " +
                     "but does not have the required attribute to establish the connection. " +
                     "Try setting another attribute to open the connection using --attribute.")
          end
          exit 10
        end
        session_from_list(list)
      end

      def search_nodes
        list = Array.new
        query = Chef::Search::Query.new
        @action_nodes = query.search(:node, @name_args[0])[0]
        @action_nodes.each do |item|
          # we should skip the loop to next iteration if the item
          # returned by the search is nil
          next if item.nil?
          # if a command line attribute was not passed, and we have a
          # cloud public_hostname, use that.  see #configure_attribute
          # for the source of config[:attribute] and
          # config[:attribute_from_cli]
          if config[:attribute_from_cli]
            Chef::Log.debug("Using node attribute '#{config[:attribute_from_cli]}' from the command line as the ssh target")
            host = extract_nested_value(item, config[:attribute_from_cli])
          elsif item[:cloud] && item[:cloud][:public_hostname]
            Chef::Log.debug("Using node attribute 'cloud[:public_hostname]' automatically as the ssh target")
            host = item[:cloud][:public_hostname]
          else
            # ssh attribute from a configuration file or the default will land here
            Chef::Log.debug("Using node attribute '#{config[:attribute]}' as the ssh target")
            host = extract_nested_value(item, config[:attribute])
          end
          # next if we couldn't find the specified attribute in the
          # returned node object
          next if host.nil?
          ssh_port = item[:cloud].nil? ? nil : item[:cloud][:public_ssh_port]
          srv = [host, ssh_port]
          list.push(srv)
        end
        list
      end

      def session_from_list(list)
        list.each do |item|
          host, ssh_port = item
          Chef::Log.debug("Adding #{host}")
          session_opts = {}

          ssh_config = Net::SSH.configuration_for(host)

          # Chef::Config[:knife][:ssh_user] is parsed in #configure_user and written to config[:ssh_user]
          user = config[:ssh_user] || ssh_config[:user]
          hostspec = user ? "#{user}@#{host}" : host
          session_opts[:keys] = File.expand_path(config[:identity_file]) if config[:identity_file]
          session_opts[:keys_only] = true if config[:identity_file]
          session_opts[:password] = config[:ssh_password] if config[:ssh_password]
          session_opts[:forward_agent] = config[:forward_agent]
          session_opts[:port] = config[:ssh_port] ||
                                ssh_port || # Use cloud port if available
                                Chef::Config[:knife][:ssh_port] ||
                                ssh_config[:port]
          session_opts[:logger] = Chef::Log.logger if Chef::Log.level == :debug

          if !config[:host_key_verify]
            session_opts[:paranoid] = false
            session_opts[:user_known_hosts_file] = "/dev/null"
          end

          session.use(hostspec, session_opts)

          @longest = host.length if host.length > @longest
        end

        session
      end

      def fixup_sudo(command)
        command.sub(/^sudo/, 'sudo -p \'knife sudo password: \'')
      end

      def print_data(host, data)
        @buffers ||= {}
        if leftover = @buffers[host]
          @buffers[host] = nil
          print_data(host, leftover + data)
        else
          if newline_index = data.index("\n")
            line = data.slice!(0...newline_index)
            data.slice!(0)
            print_line(host, line)
            print_data(host, data)
          else
            @buffers[host] = data
          end
        end
      end

      def print_line(host, data)
        padding = @longest - host.length
        str = ui.color(host, :cyan) + (" " * (padding + 1)) + data
        ui.msg(str)
      end

      def ssh_command(command, subsession=nil)
        exit_status = 0
        subsession ||= session
        command = fixup_sudo(command)
        command.force_encoding('binary') if command.respond_to?(:force_encoding)
        subsession.open_channel do |ch|
          ch.request_pty
          ch.exec command do |ch, success|
            raise ArgumentError, "Cannot execute #{command}" unless success
            ch.on_data do |ichannel, data|
              print_data(ichannel[:host], data)
              if data =~ /^knife sudo password: /
                print_data(ichannel[:host], "\n")
                ichannel.send_data("#{get_password}\n")
              end
            end
            ch.on_request "exit-status" do |ichannel, data|
              exit_status = [exit_status, data.read_long].max
            end
          end
        end
        session.loop
        exit_status
      end

      def get_password
        @password ||= prompt_for_password
      end

      def prompt_for_password(prompt = "Enter your password: ")
        ui.ask(prompt) { |q| q.echo = false }
      end

      # Present the prompt and read a single line from the console. It also
      # detects ^D and returns "exit" in that case. Adds the input to the
      # history, unless the input is empty. Loops repeatedly until a non-empty
      # line is input.
      def read_line
        loop do
          command = reader.readline("#{ui.color('knife-ssh>', :bold)} ", true)

          if command.nil?
            command = "exit"
            puts(command)
          else
            command.strip!
          end

          unless command.empty?
            return command
          end
        end
      end

      def reader
        Readline
      end

      def interactive
        puts "Connected to #{ui.list(session.servers_for.collect { |s| ui.color(s.host, :cyan) }, :inline, " and ")}"
        puts
        puts "To run a command on a list of servers, do:"
        puts "  on SERVER1 SERVER2 SERVER3; COMMAND"
        puts "  Example: on latte foamy; echo foobar"
        puts
        puts "To exit interactive mode, use 'quit!'"
        puts
        while 1
          command = read_line
          case command
          when 'quit!'
            puts 'Bye!'
            break
          when /^on (.+?); (.+)$/
            raw_list = $1.split(" ")
            server_list = Array.new
            session.servers.each do |session_server|
              server_list << session_server if raw_list.include?(session_server.host)
            end
            command = $2
            ssh_command(command, session.on(*server_list))
          else
            ssh_command(command)
          end
        end
      end

      def screen
        tf = Tempfile.new("knife-ssh-screen")
        if File.exist? "#{ENV["HOME"]}/.screenrc"
          tf.puts("source #{ENV["HOME"]}/.screenrc")
        end
        tf.puts("caption always '%-Lw%{= BW}%50>%n%f* %t%{-}%+Lw%<'")
        tf.puts("hardstatus alwayslastline 'knife ssh #{@name_args[0]}'")
        window = 0
        session.servers_for.each do |server|
          tf.print("screen -t \"#{server.host}\" #{window} ssh ")
          tf.print("-i #{config[:identity_file]} ") if config[:identity_file]
          server.user ? tf.puts("#{server.user}@#{server.host}") : tf.puts(server.host)
          window += 1
        end
        tf.close
        exec("screen -c #{tf.path}")
      end

      def tmux
        ssh_dest = lambda do |server|
          identity = "-i #{config[:identity_file]} " if config[:identity_file]
          prefix = server.user ? "#{server.user}@" : ""
          "'ssh #{identity}#{prefix}#{server.host}'"
        end

        new_window_cmds = lambda do
          if session.servers_for.size > 1
            [""] + session.servers_for[1..-1].map do |server|
              "new-window -a -n '#{server.host}' #{ssh_dest.call(server)}"
            end
          else
            []
          end.join(" \\; ")
        end

        tmux_name = "'knife ssh #{@name_args[0].gsub(/:/,'=')}'"
        begin
          server = session.servers_for.first
          cmd = ["tmux new-session -d -s #{tmux_name}",
                 "-n '#{server.host}'", ssh_dest.call(server),
                 new_window_cmds.call].join(" ")
          shell_out!(cmd)
          exec("tmux attach-session -t #{tmux_name}")
        rescue Chef::Exceptions::Exec
        end
      end

      def macterm
        begin
          require 'appscript'
        rescue LoadError
          STDERR.puts "you need the rb-appscript gem to use knife ssh macterm. `(sudo) gem install rb-appscript` to install"
          raise
        end

        Appscript.app("/Applications/Utilities/Terminal.app").windows.first.activate
        Appscript.app("System Events").application_processes["Terminal.app"].keystroke("n", :using=>:command_down)
        term = Appscript.app('Terminal')
        window = term.windows.first.get

        (session.servers_for.size - 1).times do |i|
          window.activate
          Appscript.app("System Events").application_processes["Terminal.app"].keystroke("t", :using=>:command_down)
        end

        session.servers_for.each_with_index do |server, tab_number|
          cmd = "unset PROMPT_COMMAND; echo -e \"\\033]0;#{server.host}\\007\"; ssh #{server.user ? "#{server.user}@#{server.host}" : server.host}"
          Appscript.app('Terminal').do_script(cmd, :in => window.tabs[tab_number + 1].get)
        end
      end

      def configure_attribute
        # Setting 'knife[:ssh_attribute] = "foo"' in knife.rb => Chef::Config[:knife][:ssh_attribute] == 'foo'
        # Running 'knife ssh -a foo' => both Chef::Config[:knife][:ssh_attribute] && config[:attribute] == foo
        # Thus we can differentiate between a config file value and a command line override at this point by checking config[:attribute]
        # We can tell here if fqdn was passed from the command line, rather than being the default, by checking config[:attribute]
        # However, after here, we cannot tell these things, so we must preserve config[:attribute]
        config[:attribute_from_cli] = config[:attribute]
        config[:attribute] = (config[:attribute_from_cli] || Chef::Config[:knife][:ssh_attribute] || "fqdn").strip
      end

      def cssh
        cssh_cmd = nil
        %w[csshX cssh].each do |cmd|
          begin
            # Unix and Mac only
            cssh_cmd = shell_out!("which #{cmd}").stdout.strip
            break
          rescue Mixlib::ShellOut::ShellCommandFailed
          end
        end
        raise Chef::Exceptions::Exec, "no command found for cssh" unless cssh_cmd

        # pass in the consolidated itentity file option to cssh(X)
        if config[:identity_file]
          cssh_cmd << " --ssh_args '-i #{File.expand_path(config[:identity_file])}'"
        end

        session.servers_for.each do |server|
          cssh_cmd << " #{server.user ? "#{server.user}@#{server.host}" : server.host}"
        end
        Chef::Log.debug("starting cssh session with command: #{cssh_cmd}")
        exec(cssh_cmd)
      end

      def get_stripped_unfrozen_value(value)
        return nil if value.nil?
        value.strip
      end

      def configure_user
        config[:ssh_user] = get_stripped_unfrozen_value(config[:ssh_user] ||
                             Chef::Config[:knife][:ssh_user])
      end

      # This is a bit overly complicated because of the way we want knife ssh to work with -P causing a password prompt for
      # the user, but we have to be conscious that this code gets included in knife bootstrap and knife * server create as
      # well.  We want to change the semantics so that the default is false and 'nil' means -P without an argument on the
      # command line.  But the other utilities expect nil to be the default and we can't prompt in that case. So we effectively
      # use ssh_password_ng to determine if we're coming from knife ssh or from the other utilities.  The other utilties can
      # also be patched to use ssh_password_ng easily as long they follow the convention that the default is false.
      def configure_password
        if config.has_key?(:ssh_password_ng) && config[:ssh_password_ng].nil?
          # If the parameter is called on the command line with no value
          # it will set :ssh_password_ng = nil
          # This is where we want to trigger a prompt for password
          config[:ssh_password] = get_password
        else
          # if ssh_password_ng is false then it has not been set at all, and we may be in knife ec2 and still
          # using an old config[:ssh_password].  this is backwards compatibility.  all knife cloud plugins should
          # be updated to use ssh_password_ng with a default of false and ssh_password should be retired, (but
          # we'll still need to use the ssh_password out of knife.rb if we find that).
          ssh_password = config.has_key?(:ssh_password_ng) ? config[:ssh_password_ng] : config[:ssh_password]
          # Otherwise, the password has either been specified on the command line,
          # in knife.rb, or key based auth will be attempted
          config[:ssh_password] = get_stripped_unfrozen_value(ssh_password ||
                             Chef::Config[:knife][:ssh_password])
        end
      end

      def configure_identity_file
        config[:identity_file] = get_stripped_unfrozen_value(config[:identity_file] ||
                             Chef::Config[:knife][:ssh_identity_file])
      end

      def extract_nested_value(data_structure, path_spec)
        ui.presenter.extract_nested_value(data_structure, path_spec)
      end

      def run
        extend Chef::Mixin::Command

        @longest = 0

        configure_attribute
        configure_user
        configure_password
        configure_identity_file
        configure_gateway
        configure_session

        exit_status =
        case @name_args[1]
        when "interactive"
          interactive
        when "screen"
          screen
        when "tmux"
          tmux
        when "macterm"
          macterm
        when "cssh"
          cssh
        when "csshx"
          Chef::Log.warn("knife ssh csshx will be deprecated in a future release")
          Chef::Log.warn("please use knife ssh cssh instead")
          cssh
        else
          ssh_command(@name_args[1..-1].join(" "))
        end

        session.close
        if exit_status != 0
          exit exit_status
        else
          exit_status
        end
      end

      private :search_nodes

    end
  end
end
