# === Synopsis:
#   RightScale Thunker (rs_thunk) - (c) 2014 RightScale Inc
#
#   Thunker allows you to manage custom SSH logins for non root users. It uses the rightscale
#   user and group RightScale to give privileges for authorization, managing the instance
#   under individual users' profiles. It also downloads profile tarballs containing e.g.
#   bash config.
#
## === Usage
#    rs_thunk --username USERNAME --email EMAIL [--profile DATA] [--force]
#
# === Options:
# --uuid,      -i UUID      Integer UUID for this user, obtained from RightScale
# --username,  -u USERNAME  Local Unix account name for this user
# --email,     -e EMAIL     Create audit entry saying "EMAIL logged in as USERNAME"
# --profile,   -p DATA      Extra profile data (e.g. a URL to download)
# --superuser, -s           Create account with superuser privilege
# --force,     -f           If profile option was specified - rewrite existing files
# --help:                   Display help
# --version:                Display version information
#
# === Examples:
#   Authorize as 'alice' with UUID 12345 and email address alice@example.com:
#     rs_thunk -i 12345 -u alice -e alice@example.com
#
require 'rubygems'
require 'trollop'
require 'shellwords'
require 'right_agent'
require 'right_agent/scripts/usage'
require 'right_agent/scripts/common_parser'
require 'right_agent/core_payload_types'
require 'right_support/net'
require File.normalize_path(File.join(File.dirname(__FILE__), 'command_helper'))

basedir = File.expand_path('../..', __FILE__)
require File.join(basedir, 'lib', 'instance')

module RightScale

  class Thunker
    include CommandHelper

    SCP_COMMAND  = %r{^[A-Za-z0-9_/]*scp}
    SFTP_COMMAND = %r{^[A-Za-z0-9_/]*sftp-server}
    AUDIT_REQUEST_TIMEOUT = 15 # best-effort auditing, but try not to block user

    # Manage individual user SSH logins
    #
    # === Parameters
    # options(Hash):: Hash of options as defined in +parse_args+
    #
    # === Return
    # true:: Always return true
    def run(options)
      @log_sink = StringIO.new
      @log = Logger.new(@log_sink)
      RightScale::Log.force_logger(@log)
      check_privileges if right_agent_running?

      username  = options.delete(:username)
      email     = options.delete(:email)
      uuid      = options.delete(:uuid)
      superuser = options.delete(:superuser)
      profile   = options.delete(:profile)
      force     = options.delete(:force)

      fail(1) if missing_argument(username, "USERNAME") || missing_argument(email, "EMAIL") || missing_argument(uuid, "UUID")

      # Fetch some information about the client's intentions and origin
      orig = ENV['SSH2_ORIGINAL_COMMAND'] || ENV['SSH_ORIGINAL_COMMAND']
      client_ip = ENV['SSH_CLIENT'].split(/\s+/).first if ENV.has_key?('SSH_CLIENT')


      if orig =~ SCP_COMMAND
        access = :scp
      elsif orig =~ SFTP_COMMAND
        access = :sftp
      elsif (orig != nil) && (!orig.empty?)
        access = :command
      else
        access = :shell
      end

      # Create user just-in-time; idempotent if user already exists
      # Note that username == chosen here, they just get used in two different contexts
      username = LoginUserManager.create_user(username, uuid, superuser ? true : false) do |chosen|
          puts "Creating your user profile (#{chosen}) on this machine." if (:shell == access)
      end

      create_audit_entry(email, username, access, orig, client_ip) if right_agent_running?
      chown_tty(username)

      # Note that when execing sudo we use the N-argument form of Kernel.exec,
      # which does not invoke a shell, but rather directly invokes the command specified
      # by argv[0] and uses argv[1..N] as the command line. This protects us against shell
      # escape characters and other badness.
      #
      # Unfortunately, this means that file globs and other 'useful' shell escape characters
      # do not get parsed.
      #
      # As a workaround, for non-interactive access types, we tell sudo to invoke a shell and
      # use the shell's '-c' argument to specify the command to run. We also use the -H
      # argument to sudo, which forces it to set HOME to the user's homedir. We attempt to
      # set some other environment variables to make the user feel more at home, but we
      # are at the mercy of sudo.
      #
      # For interactive logins, we don't need to perform any trickiness since our goal is
      # simply to get the user into a shell, with no command line args to parse.
      case access
      when :scp, :sftp, :command
        LoginUserManager.simulate_login(username)
        Kernel.exec('sudo', '-H', '-u', username, '/bin/sh', '-c', "cd $HOME ; #{orig}")
      when :shell
        if right_agent_running?
          display_motd
        else
          display_right_link_is_not_running_warning
        end
        Kernel.exec('sudo', '-i', '-u', username)
      end
    rescue SystemExit => e
      raise e
    rescue Exception => e
      fail(e)
    end

    # Create options hash from command line arguments
    #
    # === Return
    # options(Hash):: Hash of options as defined by the command line
    def parse_args
      parser = Trollop::Parser.new do
       opt :username, "", :type => :string
       opt :email, "", :type => :string
       opt :uuid, "", :type => :string, :short => "-i"
       opt :superuser
       opt :profile, "", :type => :string
       opt :force
       version ""
      end

      parse do
        parser.parse
      end
    end

    protected

    # Checks if argument is missing; shows error message
    #
    # === Parameters
    # parameter(String):: parameter
    # name(String):: parameter's name
    #
    # == Return
    # missing(Boolean):: true if parameter is missing
    def missing_argument(parameter, name)
      unless parameter
        STDERR.puts "Missing argument #{name}, rs_thunk --help for additional information"
        return true
      end

      return false
    end

    # Print error on console and exit abnormally
    #
    # === Parameters
    # msg(String):: Error message, default to nil (no message printed)
    # print_usage(Boolean):: Whether script usage should be printed, default to false
    #
    # === Return
    # R.I.P. does not return
    def fail(reason=nil, options={})
      case reason
      when Errno::EACCES
        STDERR.puts reason.message
        STDERR.puts "Try elevating privilege (sudo/runas) before invoking this command."
        code = 2
      when Exception
        STDOUT.puts "Unexpected #{reason.class.name}: #{reason.message}"
        STDOUT.puts "We apologize for the inconvenience. You may try connecting as root"
        STDOUT.puts "to work around this problem, if you have sufficient privilege."
        unless @log_sink.string.empty?
          STDERR.puts
          STDERR.puts("Debugging information:")
          STDERR.puts(@log_sink.string)
        end
        code = 50
      when String
        STDOUT.puts reason
        code = 50
      when Integer
        code = reason
      else
        code = 50
      end

      puts Usage.scan(__FILE__) if options[:print_usage]
      exit(code)
    end

    # Create an audit entry to record this user's access. The entry is created
    # asynchronously and this method never raises exceptions even if the
    # request fails or times out. Thus, this is "best-effort" auditing and
    # should not be relied upon!
    #
    # === Parameters
    # email(String):: the user's email address
    # username(String):: the user's email address
    # access(Symbol):: mode of access; one of :scp, :sftp, :command or :shell
    # command(String):: exact command that is being executed via SSH
    # client_ip(String):: origin IP address
    #
    # === Return
    # Returns true on success, false otherwise
    def create_audit_entry(email, username, access, command, client_ip=nil)
      begin
        hostname = `hostname`.strip
      rescue Exception => e
        hostname = 'localhost'
      end

      case access
        when :scp then
          summary  = 'SSH file copy'
          detail   = "User copied files copied (scp) to/from host."
        when :sftp then
          summary  = 'SSH interactive file transfer'
          detail   = "User initiated an SFTP session."
        when :command
          summary  = 'SSH command'
          detail   = "User invoked an interactive program."
        when :shell
          summary  = 'SSH interactive login'
          detail   = "User connected and invoked a login shell."
      end

      detail += "\nLogin:     #{username}@#{hostname}" if username
      detail += "\nClient IP: #{client_ip}" if client_ip
      detail += "\nCommand:   #{command}" if command

      log_detail = detail.gsub("\n", '; ')
      Log.info("#{summary} - #{log_detail}")

      options = {
        :name => 'audit_create_entry',
        :user_email => email,
        :summary => summary,
        :detail => detail,
        :category => RightScale::EventCategories::CATEGORY_SECURITY
      }
      send_command(options, false, AUDIT_REQUEST_TIMEOUT)

      true
    rescue Exception => e
      Log.error("#{e.class.name}:#{e.message}")
      Log.error(e.backtrace.join("\n"))
      false
    end

    # Dispaly warning message
    def display_right_link_is_not_running_warning
        puts "RightLink service is not running."
        puts "Some modules may not work correctly!"
    end


    # Display the Message of the Day if it exists.
    def display_motd
      if ::File.exists?("/var/run/motd.dynamic")
        # Ubuntu 14.04+ motd location
        puts ::File.read("/var/run/motd.dynamic")
      elsif ::File.exists?("/var/run/motd")
        # Ubuntu 12.04 motd location
        puts ::File.read("/var/run/motd")
      elsif ::File.exists?("/etc/motd")
        # Legacy (CentOS 6 style)
        puts ::File.read("/etc/motd")
      end
    rescue 
      nil
    end

    # Ensure the user's PTY/TTY will be owned by him once we thunk through to his account.
    # This helps apps like screen/tmux work better.
    def chown_tty(username)
      tty = `tty 2> /dev/null`.chomp
      @log.error("'tty' command failed") unless $?.success?

      if File.exists?(tty)
        %x(sudo chown #{Shellwords.escape(username)} #{Shellwords.escape(tty)})
        raise RuntimeError, "Failed to change ownership of #{tty}" unless $?.success?
        else
          @log.error("'tty' command did not give a reasonable answer: #{tty}")
      end
    end

    # Version information
    #
    # === Return
    # (String):: Version information
    def version
      "rs_thunk #{right_link_version} - RightLink's thunker (c) 2014 RightScale"
    end

    def usage
      Usage.scan(__FILE__)
    end

  end # Thunker

end # RightScale

#
# Copyright (c) 2011 RightScale Inc
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
