#!/usr/bin/env ruby

require "logger"
require "json"
require "net/https"
require "yaml"

# Logging

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

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

# Settings

def read_settings
  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}")
  settings
end

# Cockpit protocol, encoding and decoding of control messages.

def send_control(msg)
  text = JSON.dump(msg)
  LOG.debug("Sending control message #{text}")
  $stdout.write("#{text.length+1}\n\n#{text}")
  $stdout.flush
end

def read_control
  size = $stdin.readline.chomp.to_i
  raise ArgumentError, "Invalid frame: invalid size" if size.zero?

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

  JSON.parse(data)
end

# Specific control messages

def send_auth_challenge(challenge)
  send_control({ "command" => "authorize",
    "cookie" => "1234", # must be present, but value doesn't matter
    "challenge" => challenge})
end

def send_auth_response(response)
  send_control({ "command" => "authorize",
    "response" => response})
end

def read_auth_reply
  cmd = read_control
  response = cmd["response"]
  raise ArgumentError, "Did not receive a valid authorize command" if cmd["command"] != "authorize" || !response

  response
end

def exit_with_problem(problem, message, auth_methods)
  LOG.error("#{problem} - #{message}")
  send_control({ "command" => "init",
    "problem" => problem,
    "message" => message,
    "auth-method-results" => auth_methods})
  exit 1
end

# Talking to Foreman

def get_token_from_auth_data(auth_data)
  auth_data.split(" ")[1]
end

def foreman_call(path, token)
  foreman = SETTINGS[:foreman_url] || "https://localhost/"
  uri = URI(foreman + "/" + path)

  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[: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
    safe_log("Foreman response #{res.code} - %s", body)
  end

  case res.code
  when "200"
    return JSON.parse(res.body)
  when "401"
    exit_with_problem("authentication-failed",
      "Token was not valid",
      { "password" => "not-tried", "token" => "denied" })
  when "404"
    return nil
  else
    LOG.error("Error talking to foreman: #{res.body}\n")
    exit 1
  end
end

# SSH via the smart proxy

def ssh_write_request_header(url, sock, params)
  data = JSON.dump(params) + "\r\n"
  sock.write("POST /ssh/session HTTP/1.1\r\nHost: #{url.host}:#{url.port}\r\nConnection: upgrade\r\nUpgrade: raw\r\nContent-Length: #{data.length}\r\n\r\n#{data}")
  sock.flush
end

def ssh_read_and_handle_response_header(sock, url, params)
  header = ""
  loop do
    line = sock.readline
    break unless line && (line != "\r\n")

    header += line
  end

  status_line, headers_text = header.split("\r\n", 2)
  status = status_line.split(" ")[1]
  if status != "101"
    m = /^Content-Length:[ \t]*([0-9]+)\r?$/i.match(headers_text)
    expected_len = if m
      m[1].to_i
                   else
      -1
                   end
    response = ""
    while expected_len < 0 || response.length < expected_len
      begin
        response += sock.readpartial(4096)
      rescue EOFError
        break
      end
    end
    if status == "404"
      exit_with_problem("access-denied", "The proxy #{url.hostname} does not support web console sessions", nil)
    elsif status[0] == "4"
      if response.include? "cockpit-bridge: command not found"
        exit_with_problem("access-denied", "#{params['hostname']} has no web console", nil)
      else
        exit_with_problem("access-denied", response, nil)
      end
    else
      LOG.error("Error talking to smart proxy: #{response}\n")
      exit 1
    end
  end
end

def ssh_read_sock(sock)
  data = ""
  begin
    loop do
      data += sock.read_nonblock(4096)
    end
  rescue IO::WaitReadable
    data
  rescue IO::WaitWritable
    # This might happen with SSL during a renegotiation.  Block a
    # bit to get it over with.
    IO.select(nil, [sock])
    retry
  end
  data
end

def ssh_write_sock(sock, data)
    sock.write_nonblock(data)
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([sock])
    retry
end

def ssh_with_proxy(proxy, params)
  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[:ssl_certificate]))
    ssl_context.key = OpenSSL::PKey.read(File.read(SETTINGS[:ssl_private_key]))
    sock = OpenSSL::SSL::SSLSocket.new(raw_sock, ssl_context)
    sock.sync_close = true
    sock.connect
  else
    sock = raw_sock
  end

  ssh_write_request_header(url, sock, params)
  ssh_read_and_handle_response_header(sock, url, params)

  inp_buf = ""
  out_buf = ssh_read_sock(sock)

  ws_eof = false
  bridge_eof = false

  loop do
    readers = [ ]
    writers = [ ]

    readers += [ $stdin ] unless ws_eof
    readers += [ sock ] unless bridge_eof
    writers += [ $stdout ] unless out_buf == ""
    writers += [ sock ] unless inp_buf == ""

    break if readers.length + writers.length == 0

    r, w, x = IO.select(readers, writers)

    if r.include?(sock)
      begin
        out_buf += ssh_read_sock(sock)
      rescue EOFError
        bridge_eof = true
        break if out_buf == ""
      end
    end

    if w.include?(sock)
      begin
        n = ssh_write_sock(sock, inp_buf)
        inp_buf = inp_buf[n..-1]
        raw_sock.close_write if (inp_buf == "") && ws_eof
      end
    end

    if r.include?($stdin)
      begin
        inp_buf += $stdin.readpartial(4096)
      rescue EOFError
        ws_eof = true
        raw_sock.close_write if inp_buf == ""
      end
    end

    next unless w.include?($stdout)

    n = $stdout.write(out_buf)
    $stdout.flush
    out_buf = out_buf[n..-1]
    break if (out_buf == "") && bridge_eof

  end
end

# Main

SETTINGS = read_settings

host = ARGV[0]

send_auth_challenge("*")
token = get_token_from_auth_data(read_auth_reply)

params = foreman_call("cockpit/host_ssh_params/#{host}", token)
exit_with_problem("access-denied", "Host #{host} is not known", nil) unless params

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

params["command"] = "cockpit-bridge"
case params["proxy"]
when "not_available"
  exit_with_problem("access-denied", "A proxy is required to reach #{host} but all of them are down", nil)
when "not_defined"
  exit_with_problem("access-denied", "A proxy is required to reach #{host} but none has been configured", nil)
when "direct"
  exit_with_problem("access-denied", "Web console sessions require a proxy but none has been configured", nil)
else
  ssh_with_proxy(params["proxy"], params)
end
