# frozen_string_literal: true require 'logging' require 'shellwords' require 'bolt/node/errors' require 'bolt/node/output' require 'bolt/util' require 'net/ssh/proxy/jump' module Bolt module Transport class SSH < Base class Connection class RemoteTempdir def initialize(node, path) @node = node @owner = node.user @path = path @logger = node.logger end def to_s @path end def mkdirs(subdirs) abs_subdirs = subdirs.map { |subdir| File.join(@path, subdir) } result = @node.execute(['mkdir', '-p'] + abs_subdirs) if result.exit_code != 0 message = "Could not create subdirectories in '#{@path}': #{result.stderr.string}" raise Bolt::Node::FileError.new(message, 'MKDIR_ERROR') end end def chown(owner) return if owner.nil? || owner == @owner result = @node.execute(['id', '-g', owner]) if result.exit_code != 0 message = "Could not identify group of user #{owner}: #{result.stderr.string}" raise Bolt::Node::FileError.new(message, 'ID_ERROR') end group = result.stdout.string.chomp # Chown can only be run by root. result = @node.execute(['chown', '-R', "#{owner}:#{group}", @path], sudoable: true, run_as: 'root') if result.exit_code != 0 message = "Could not change owner of '#{@path}' to #{owner}: #{result.stderr.string}" raise Bolt::Node::FileError.new(message, 'CHOWN_ERROR') end # File ownership successfully changed, record the new owner. @owner = owner end def delete result = @node.execute(['rm', '-rf', @path], sudoable: true, run_as: @owner) if result.exit_code != 0 @logger.warn("Failed to clean up tempdir '#{@path}': #{result.stderr.string}") end end end attr_reader :logger, :user, :target attr_writer :run_as def initialize(target, transport_logger, load_config = true) @target = target @load_config = load_config ssh_user = load_config ? Net::SSH::Config.for(target.host)[:user] : nil @user = @target.user || ssh_user || Etc.getlogin @run_as = nil @logger = Logging.logger[@target.host] @transport_logger = transport_logger end if Bolt::Util.windows? require 'ffi' module Win extend FFI::Library ffi_lib 'user32' ffi_convention :stdcall attach_function :FindWindow, :FindWindowW, %i[buffer_in buffer_in], :int end end def connect options = { logger: @transport_logger, non_interactive: true } if (key = target.options['private-key']) if key.instance_of?(String) options[:keys] = key else options[:key_data] = [key['key-data']] end end options[:port] = target.port if target.port options[:password] = target.password if target.password # Support both net-ssh 4 and 5. We use 5 in packaging, but Beaker pins to 4 so we # want the gem to be compatible with version 4. options[:verify_host_key] = if target.options['host-key-check'] if defined?(Net::SSH::Verifiers::Always) Net::SSH::Verifiers::Always.new else Net::SSH::Verifiers::Secure.new end elsif defined?(Net::SSH::Verifiers::Never) Net::SSH::Verifiers::Never.new else Net::SSH::Verifiers::Null.new end options[:timeout] = target.options['connect-timeout'] if target.options['connect-timeout'] options[:proxy] = Net::SSH::Proxy::Jump.new(target.options['proxyjump']) if target.options['proxyjump'] if @load_config # Mirroring: # https://github.com/net-ssh/net-ssh/blob/master/lib/net/ssh/authentication/agent.rb#L80 # https://github.com/net-ssh/net-ssh/blob/master/lib/net/ssh/authentication/pageant.rb#L403 if defined?(UNIXSocket) && UNIXSocket if ENV['SSH_AUTH_SOCK'].to_s.empty? @logger.debug { "Disabling use_agent in net-ssh: ssh-agent is not available" } options[:use_agent] = false end elsif Bolt::Util.windows? pageant_wide = 'Pageant'.encode('UTF-16LE') if Win.FindWindow(pageant_wide, pageant_wide).to_i == 0 @logger.debug { "Disabling use_agent in net-ssh: pageant process not running" } options[:use_agent] = false end end else # Disable ssh config and ssh-agent if requested via load_config options[:config] = false options[:use_agent] = false end @session = Net::SSH.start(target.host, @user, options) @logger.debug { "Opened session" } rescue Net::SSH::AuthenticationFailed => e raise Bolt::Node::ConnectError.new( e.message, 'AUTH_ERROR' ) rescue Net::SSH::HostKeyError => e raise Bolt::Node::ConnectError.new( "Host key verification failed for #{target.uri}: #{e.message}", 'HOST_KEY_ERROR' ) rescue Net::SSH::ConnectionTimeout raise Bolt::Node::ConnectError.new( "Timeout after #{target.options['connect-timeout']} seconds connecting to #{target.uri}", 'CONNECT_ERROR' ) rescue StandardError => e raise Bolt::Node::ConnectError.new( "Failed to connect to #{target.uri}: #{e.message}", 'CONNECT_ERROR' ) end def disconnect if @session && !@session.closed? @session.close @logger.debug { "Closed session" } end end # This method allows the @run_as variable to be used as a per-operation # override for the user to run as. When @run_as is unset, the user # specified on the target will be used. def run_as @run_as || target.options['run-as'] end # Run as the specified user for the duration of the block. def running_as(user) @run_as = user yield ensure @run_as = nil end def sudo_prompt '[sudo] Bolt needs to run as another user, password: ' end def handled_sudo(channel, data) if data.lines.include?(sudo_prompt) if target.options['sudo-password'] channel.send_data "#{target.options['sudo-password']}\n" channel.wait return true else # Cancel the sudo prompt to prevent later commands getting stuck channel.close raise Bolt::Node::EscalateError.new( "Sudo password for user #{@user} was not provided for #{target.uri}", 'NO_PASSWORD' ) end elsif data =~ /^#{@user} is not in the sudoers file\./ @logger.debug { data } raise Bolt::Node::EscalateError.new( "User #{@user} does not have sudo permission on #{target.uri}", 'SUDO_DENIED' ) elsif data =~ /^Sorry, try again\./ @logger.debug { data } raise Bolt::Node::EscalateError.new( "Sudo password for user #{@user} not recognized on #{target.uri}", 'BAD_PASSWORD' ) end false end def execute(command, sudoable: false, **options) result_output = Bolt::Node::Output.new run_as = options[:run_as] || self.run_as escalate = sudoable && run_as && @user != run_as use_sudo = escalate && @target.options['run-as-command'].nil? command_str = command.is_a?(String) ? command : Shellwords.shelljoin(command) if escalate if use_sudo sudo_flags = ["sudo", "-S", "-u", run_as, "-p", sudo_prompt] sudo_flags += ["-E"] if options[:environment] sudo_str = Shellwords.shelljoin(sudo_flags) command_str = "#{sudo_str} #{command_str}" else run_as_str = Shellwords.shelljoin(@target.options['run-as-command'] + [run_as]) command_str = "#{run_as_str} #{command_str}" end end # Including the environment declarations in the shelljoin will escape # the = sign, so we have to handle them separately. if options[:environment] env_decls = options[:environment].map do |env, val| "#{env}=#{Shellwords.shellescape(val)}" end command_str = "#{env_decls.join(' ')} #{command_str}" end @logger.debug { "Executing: #{command_str}" } session_channel = @session.open_channel do |channel| # Request a pseudo tty channel.request_pty if target.options['tty'] channel.exec(command_str) do |_, success| unless success raise Bolt::Node::ConnectError.new( "Could not execute command: #{command_str.inspect}", 'EXEC_ERROR' ) end channel.on_data do |_, data| unless use_sudo && handled_sudo(channel, data) result_output.stdout << data end @logger.debug { "stdout: #{data.strip}" } end channel.on_extended_data do |_, _, data| unless use_sudo && handled_sudo(channel, data) result_output.stderr << data end @logger.debug { "stderr: #{data.strip}" } end channel.on_request("exit-status") do |_, data| result_output.exit_code = data.read_long end if options[:stdin] channel.send_data(options[:stdin]) channel.eof! end end end session_channel.wait if result_output.exit_code == 0 @logger.debug { "Command returned successfully" } else @logger.info { "Command failed with exit code #{result_output.exit_code}" } end result_output rescue StandardError @logger.debug { "Command aborted" } raise end def write_remote_file(source, destination) @session.scp.upload!(source, destination, recursive: true) rescue StandardError => e raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR') end def make_tempdir tmpdir = target.options.fetch('tmpdir', '/tmp') tmppath = "#{tmpdir}/#{SecureRandom.uuid}" command = ['mkdir', '-m', 700, tmppath] result = execute(command) if result.exit_code != 0 raise Bolt::Node::FileError.new("Could not make tempdir: #{result.stderr.string}", 'TEMPDIR_ERROR') end path = tmppath || result.stdout.string.chomp RemoteTempdir.new(self, path) end # A helper to create and delete a tempdir on the remote system. Yields the # directory name. def with_remote_tempdir dir = make_tempdir yield dir ensure dir&.delete end def write_remote_executable(dir, file, filename = nil) filename ||= File.basename(file) remote_path = File.join(dir.to_s, filename) write_remote_file(file, remote_path) make_executable(remote_path) remote_path end def write_executable_from_content(dest, content, filename) remote_path = File.join(dest.to_s, filename) @session.scp.upload!(StringIO.new(content), remote_path) make_executable(remote_path) remote_path end def make_executable(path) result = execute(['chmod', 'u+x', path]) if result.exit_code != 0 message = "Could not make file '#{path}' executable: #{result.stderr.string}" raise Bolt::Node::FileError.new(message, 'CHMOD_ERROR') end end end end end end