#!/usr/bin/env ruby

require 'logger'
require 'json'
require 'net/http'
require 'net/https'
require 'stringio'
require 'yaml'
require 'singleton'

# Logging
LOG = Logger.new($stderr)
LOG.formatter = proc { |severity, datetime, progname, msg| "#{severity}: #{msg}\n" }

class Settings
  include Singleton

  def initialize
    @settings = {}
  end

  def load!
    settings_path = ENV['FOREMAN_COCKPIT_SETTINGS'] || '/etc/foreman-cockpit/settings.yml'
    @settings = YAML.safe_load(File.read(settings_path), [Symbol])
    LOG.level = Logger.const_get(@settings.fetch(:log_level, 'INFO'))
    LOG.info("Running foreman-cockpit-session with settings from #{settings_path}:\n#{@settings.inspect}")
  end

  def [](key)
    @settings[key]
  end
end

class CockpitError < StandardError
  attr_reader :additional

  def initialize(message, additional = nil)
    @additional = additional
    super message
  end
end

class AuthenticationError < CockpitError; end
class AccessDeniedError < CockpitError; end

class Cockpit
  class << self
    def encode_message(payload)
      data = JSON.dump(payload)
      "#{data.length + 1}\n\n#{data}"
    end

    def send_control(io, msg)
      LOG.debug("Sending control message #{msg}")
      io.write(encode_message(msg))
      io.flush
    end

    def read_control(io, fatal: false)
      size = io.readline.chomp.to_i
      raise ArgumentError, 'Invalid frame: invalid size' if size.zero?

      data = io.read(size)
      LOG.debug("Received control message #{data.lstrip}")
      raise ArgumentError, 'Invalid frame: too short' if data.nil? || data.length < size

      JSON.parse(data)
    rescue JSON::ParserError, ArgumentError => e
      raise e if fatal
    end
  end
end

class Utils
  class << self
    def safe_log(format_string, data = nil)
      if data.is_a? Hash
        data = data.dup
        data.each_key do |key|
          data[key] = '*******' if key.to_s =~ /password|passphrase/
        end
      end
      format_string % [data]
    end
  end
end

class ProxyBuffer
  attr_reader :src_io, :dst_io, :buffer

  def initialize(src_io, dst_io)
    @src_io = src_io
    @dst_io = dst_io
    @buffer = ''
  end

  def close
    @src_io.close unless @src_io.closed?
    @dst_io.close unless @dst_io.closed?
  end

  def read_available!
    data = ''
    loop { data += @src_io.read_nonblock(4096) }
  rescue IO::WaitReadable
  rescue IO::WaitWritable
    # This might happen with SSL during a renegotiation.  Block a
    # bit to get it over with.
    IO.select(nil, [@src_io])
    retry
  rescue EOFError
    @src_io.close unless @src_io.closed?
  ensure
    @buffer += with_data_callback(data)
  end

  def with_data_callback(data)
    if @data_callback
      @data_callback.call(data)
    else
      data
    end
  end

  def write_available!
    count = @dst_io.write_nonblock(@buffer)
    @buffer = @buffer[count..]
  rescue IO::WaitWritable
    0
  rescue IO::WaitReadable
    # This might happen with SSL during a renegotiation.  Block a
    # bit to get it over with.
    IO.select([@dst_io])
    retry
  end

  def flush_pending_writes!
    write_available! until @buffer.empty?
  end

  def pending_writes?
    !(@buffer.empty? || @dst_io.closed?)
  end

  def readable?
    !@src_io.closed?
  end

  def enqueue(data)
    @buffer += data
  end

  def on_data(&block)
    @data_callback = block
  end
end

class Relay
  attr_reader :proxy

  def self.start(proxy, params)
    new(proxy, params).run
  end

  def run
    initialize_proxy_connection!
    proxy_loop
  end

  def initialize(proxy, params)
    @proxy = proxy
    @params = params
    @inject_authorization = @params['ssh_user'] != 'root' && @params['effective_user_password']
  end

  def proxy_loop
    proxy1 = ProxyBuffer.new($stdin, @sock)
    proxy2 = ProxyBuffer.new(@sock, $stdout)
    proxy2.on_data do |data|
      if @inject_authorization
        sio = StringIO.new(data)
        begin
          message = Cockpit.read_control(sio)
        rescue StandardError
          # We're looking for one specific message, but the expectation that one
          # invocation of this callback processes one message doesn't really
          # hold. The message we're looking for is sent quite early in the
          # communication, if at all, so the chance that it will be aligned with
          # the beginning of the buffer is quite high. If we somehow fail to
          # process the contents of the buffer, we should just carry on.
          #
          # With the authorization injection check in place, this is more of a
          # precaution so that unexpectedly big message won't bring the entire
          # thing down.
        end
        if message.is_a?(Hash) && message['command'] == 'authorize'
          response = {
            'command' => 'authorize',
            'cookie' => message['cookie'],
            'response' => @params['effective_user_password'],
          }
          proxy1.enqueue(Cockpit.encode_message(response))
          @inject_authorization = false
          data = sio.read # Return whatever was left unread after read_control
        end
      end
      data
    end

    proxies = [proxy1, proxy2]

    loop do
      writers = proxies.select(&:pending_writes?)
      readers = proxies.select(&:readable?)

      break if readers.empty? && writers.empty?

      r, w = select(readers, writers)

      r.each(&:read_available!)
      w.each(&:flush_pending_writes!)
    end
  ensure
    proxies.each(&:close)
    @raw_sock.close
  end

  private

  def select(readers, writers)
    r_ios, w_ios, = IO.select(readers.map(&:src_io), writers.map(&:dst_io))

    [ r_ios.map { |io| readers.find { |r| r.src_io == io } },
      w_ios.map { |io| writers.find { |w| w.dst_io == io } } ]
  end

  def initialize_proxy_connection!
    url = URI(proxy)
    LOG.debug("Connecting to proxy at #{url}")
    @raw_sock = TCPSocket.open(url.hostname, url.port)
    if url.scheme == 'https'
      ssl_context = OpenSSL::SSL::SSLContext.new
      ssl_context.cert = OpenSSL::X509::Certificate.new(File.read(Settings.instance[:ssl_certificate]))
      ssl_context.key = OpenSSL::PKey.read(File.read(Settings.instance[:ssl_private_key]))
      @sock = OpenSSL::SSL::SSLSocket.new(@raw_sock, ssl_context)
      @sock.sync_close = true
      @sock.connect
    else
      @sock = raw_sock
    end

    upgrade_connection!(url)
  end

  def upgrade_connection!(url)
    data = JSON.dump(@params)
    payload = <<~HTTP
      POST /ssh/session HTTP/1.1
      Host: #{url.host}:#{url.port}
      Connection: upgrade
      Upgrade: raw
      Content-Length: #{data.length + 2}

      #{data}
    HTTP

    @sock.write(payload.gsub("\n", "\r\n"))
    @sock.flush

    buf_io = Net::BufferedIO.new(@sock)

    # This is ugly, but Net::HTTP doesn't seem to be able to parse upgrade replies properly
    headers = {}
    Net::HTTPResponse.send(:each_response_header, buf_io) { |key, value| headers[key] = value }

    status = headers['Status'].to_i
    body = buf_io.read(headers['Content-Length'].to_i)
    case status
    when 101
      return
    when 404
      raise AccessDeniedError, "The proxy #{url.hostname} does not support web console sessions"
    when (400..499)
      message = if body.include? 'cockpit-bridge: command not found'
                  "#{params['hostname']} has no web console"
                else
                  body
                end
      raise AccessDeniedError, message
    else
      raise CockpitError, "Error talking to smart proxy: #{body}"
    end
  end
end

class Session
  def initialize(host)
    @host = host
  end

  def run
    send_auth_challenge('*')
    token = read_auth_reply.match(/^Bearer (.*)$/)[1]
    params = get_host_params(token)

    LOG.debug(Utils.safe_log('SSH parameters %s', params))

    params['command'] = 'cockpit-bridge'
    case params['proxy']
    when 'not_available'
      raise AccessDeniedError, "A proxy is required to reach #{@host} but all of them are down"
    when 'not_defined'
      raise AccessDeniedError, "A proxy is required to reach #{@host} but none has been configured"
    when 'direct'
      raise AccessDeniedError, 'Web console sessions require a proxy but none has been configured'
    else
      Relay.start(params['proxy'], params)
    end
  rescue CockpitError => e
    exit_with_error(e)
  end

  def exit_with_error(exception)
    problem = case exception
              when AuthenticationError
                'authentication-failed'
              when AccessDeniedError
                'access-denied'
              else
                'error'
              end

    Cockpit.send_control($stdout, { 'command' => 'init',
      'problem' => problem,
      'message' => exception.message,
      'auth-method-results' => exception.additional})
    exit 1
  end

  def get_host_params(token)
    foreman = Settings.instance[:foreman_url] || 'https://localhost/'
    uri = URI(foreman + '/' + 'cockpit/host_ssh_params/' + @host)

    LOG.debug("Foreman request GET #{uri}")

    http = Net::HTTP.new(uri.hostname, uri.port)
    if uri.scheme == 'https'
      http.use_ssl = true
      http.verify_mode = OpenSSL::SSL::VERIFY_PEER
      http.ca_file = Settings.instance[:ssl_ca_file]
    end

    req = Net::HTTP::Get.new(uri)
    req['Cookie'] = "_session_id=#{token}"
    res = http.request(req)

    LOG.debug do
      body = JSON.parse(res.body) rescue res.body
      Utils.safe_log("Foreman response #{res.code} - %s", body)
    end

    case res.code.to_i
    when 200
      return JSON.parse(res.body)
    when 401
      raise AuthenticationError, 'Token was not valid', { 'password' => 'not-tried', 'token' => 'denied' }
    when 404
      raise AccessDeniedError, "Host #{@host} is not known"
    else
      raise CockpitError, "Error talking to Foreman: #{res.body}"
    end
  end

  # Specific control messages
  def send_auth_challenge(challenge)
    Cockpit.send_control($stdout, { 'command' => 'authorize',
      'cookie' => '1234', # must be present, but value doesn't matter
      'challenge' => challenge})
  end

  def read_auth_reply
    cmd = Cockpit.read_control($stdin, fatal: true)
    response = cmd['response']
    raise ArgumentError, 'Did not receive a valid authorize command' if cmd['command'] != 'authorize' || !response

    response
  end
end

# Load the settings
Settings.instance.load!
Session.new(ARGV[0]).run
