module Aptible::CLI::Helpers::Ssh

Public Instance Methods

connect_to_ssh_portal(operation, *extra_ssh_args) click to toggle source
# File lib/aptible/cli/helpers/ssh.rb, line 9
def connect_to_ssh_portal(operation, *extra_ssh_args)
  # NOTE: This is a little tricky to get rigt, so before you make any
  # changes, read this.
  #
  # - The first gotcha is that we cannot use Kernel.exec here, because
  # we need to perform cleanup when exiting from
  # operation#with_ssh_cmd.
  #
  # - The second gotcha is that we need to somehow capture the exit
  # status, so that CLI commands that call the SSH portal can proxy
  # this back to their own caller (the most important one here is
  # aptible ssh).
  #
  # To do this, we have to handle interrutps as a signal, as opposed to
  # handle an Interrupt exception. The reason for this has to do with
  # how Ruby's wait is implemented (this happens in process.c's
  # rb_waitpid). There are two main considerations here:
  #
  # - It automatically resumes when it receives EINTR, so our control
  # is pretty high-level here.
  # - It handles interrupts prior to setting $? (this appears to have
  # changed between Ruby 2.2 and 2.3, perhaps the newer implementation
  # behaves differently).
  #
  # Unfortunately, this means that if we receive SIGINT while in
  # Process::wait2, then we never get access to SSH's exitstatus: Ruby
  # throws a Interrupt so we don't have a return value, and it doesn't
  # set $?, so we can't read it back there.
  #
  # Of course, we can't just call Proces::wait2 again, because at this
  # point, we've reaped our child.
  #
  # To solve this, we add our own signal handler on SIGINT, which
  # simply proxies SIGINT to SSH if we happen to have a different
  # process group (which shouldn't be the case), just to be safe and
  # let users exit the CLI.
  with_ssh_cmd(operation) do |base_ssh_cmd|
    spawn_passthrough(base_ssh_cmd + extra_ssh_args)
  end
end
exit_with_ssh_portal(*args) click to toggle source
# File lib/aptible/cli/helpers/ssh.rb, line 50
def exit_with_ssh_portal(*args)
  exit connect_to_ssh_portal(*args)
end
with_ssh_cmd(operation) { |cmd| ... } click to toggle source
# File lib/aptible/cli/helpers/ssh.rb, line 54
def with_ssh_cmd(operation)
  ensure_ssh_dir!
  ensure_config!
  ensure_key!

  operation.with_ssh_cmd(private_key_file) do |cmd, connection|
    yield cmd + common_ssh_args, connection
  end
end

Private Instance Methods

common_ssh_args() click to toggle source
# File lib/aptible/cli/helpers/ssh.rb, line 148
def common_ssh_args
  log_level = ENV['APTIBLE_SSH_DEBUG'] ? 'DEBUG3' : 'ERROR'

  [
    '-o', 'TCPKeepAlive=yes',
    '-o', 'KeepAlive=yes',
    '-o', 'ServerAliveInterval=60',
    '-o', "LogLevel=#{log_level}",
    '-o', 'ControlMaster=no',
    '-o', 'ControlPath=none',
    '-F', ssh_config_file
  ]
end
ensure_config!() click to toggle source
# File lib/aptible/cli/helpers/ssh.rb, line 104
def ensure_config!
  return if File.exist?(ssh_config_file)
  File.open(ssh_config_file, 'w', 0o600) { |f| f.write('') }
end
ensure_key!() click to toggle source
# File lib/aptible/cli/helpers/ssh.rb, line 109
def ensure_key!
  key_files = [private_key_file, public_key_file]
  return if key_files.all? { |f| File.exist?(f) }

  # If we're missing *some* files, then we should clean them up.

  key_files.each do |key_file|
    begin
      File.delete(key_file)
    rescue Errno::ENOENT
      # We don't care, that's what we want.
    end
  end

  begin
    cmd = ['ssh-keygen', '-t', 'rsa', '-N', '', '-f', private_key_file]
    out, status = Open3.capture2e(*cmd)
    raise "Failed to generate ssh key: #{out}" unless status.success?
  rescue Errno::ENOENT
    raise 'ssh-keygen must be installed'
  end
end
ensure_ssh_dir!() click to toggle source
# File lib/aptible/cli/helpers/ssh.rb, line 100
def ensure_ssh_dir!
  FileUtils.mkdir_p(ssh_dir, mode: 0o700)
end
private_key_file() click to toggle source
# File lib/aptible/cli/helpers/ssh.rb, line 140
def private_key_file
  File.join ssh_dir, 'id_rsa'
end
public_key_file() click to toggle source
# File lib/aptible/cli/helpers/ssh.rb, line 144
def public_key_file
  "#{private_key_file}.pub"
end
spawn_passthrough(command) click to toggle source
# File lib/aptible/cli/helpers/ssh.rb, line 66
def spawn_passthrough(command)
  redirection = { in: :in, out: :out, err: :err, close_others: true }
  pid = Process.spawn(*command, redirection)

  reset = Signal.trap('SIGINT') do
    # FIXME: If we're on Windows, we don't really know whether SSH
    # received SIGINT or not, so for now, we just ignore it.
    next if Gem.win_platform?

    begin
      # SSH should be running in our process group, which means that
      # if the user sends CTRL+C, we'll both receive it. In this
      # case, just ignore the signal and let SSH handle it.
      next if Process.getpgid(Process.pid) == Process.getpgid(pid)

      # If we get here, then oddly, SSH is not running in our process
      # group and yet we got the signal. In this case, let's simply
      # ignore it.
      Process.kill(:SIGINT, pid)
    rescue Errno::ESRCH
      # This could happen if SSH exited after receiving the SIGINT,
      # Ruby waited it, then ran our signal handler. In this case, we
      # don't need to do anything, so we proceed.
    end
  end

  begin
    _, status = Process.wait2(pid)
    return status.exited? ? status.exitstatus : 128 + status.termsig
  ensure
    Signal.trap('SIGINT', reset)
  end
end
ssh_config_file() click to toggle source
# File lib/aptible/cli/helpers/ssh.rb, line 136
def ssh_config_file
  File.join ssh_dir, 'config'
end
ssh_dir() click to toggle source
# File lib/aptible/cli/helpers/ssh.rb, line 132
def ssh_dir
  File.join aptible_config_path, 'ssh'
end