require "ipaddr"
require "socket"
require "optparse"
require "json"
require "securerandom"

require "xmpp4r"

puts ARGV

options = {}
OptionParser.new do |opts|
  opts.banner = "usage: ${$0} [opts]"

  opts.on("-i", "--interface=INTERFACE",
          "Publicly-accessible interface") do |interface|
    options[:interface] = interface
  end

  opts.on("-d", "--domain=DOMAIN",
          "Domain on which we wish to set the new ip") do |domain|
    options[:domain] = domain
  end

  opts.on("-s", "--server=SERVER",
          "Backplane DNS XMPP server") do |server|
    options[:server] = server
  end

  opts.on("-p", "--password-file=/path/to/file",
          "File containing password for XMPP server") do |pw_file|
    options[:pw_file] = pw_file
  end

  opts.on("-4", "--ipv4",
          "Check for a public IPv4 and register with the backplane.") do
    options[:ipv4] = true
  end

  opts.on("-6", "--ipv6",
          "Check for a public IPv6 and register with the backplane.") do
    options[:ipv6] = true
  end

  opts.on("-f", "--sshfp=FILE",
          "Register host SSH key fingerprints with the backplane.") do |file| 
    options[:sshfp] = [] if not options[:sshfp]
    options[:sshfp] = options[:sshfp] + [file]
  end
end.parse!

def error(msg)
  puts msg
  throw msg
end

error("domain is required") if not options[:domain]
error("server is required") if not options[:server]
error("password file is required") if not options[:pw_file]
error("at least one of -4 or -6 required") if not (options[:ipv4] or options[:ipv6])

if not File::readable?(options[:pw_file])
  error("file does not exist or is not readable")
end
password = File::open(options[:pw_file]) { |f| f.gets.strip }

class XMPPClient
  def initialize(domain, hostname, server, password)
    @jid = "host-#{hostname}@#{server}"
    @service_jid = "service-dns@#{server}"
    @server = server
    @domain = domain
    @password = password
    @responses = Queue.new
    @responses_lock = Mutex.new
  end

  def connect
    disconnect if connected?
    @client = Jabber::Client::new(@jid)
    @client.connect # will use SRV records
    error("failed to initialize TLS connection") if not @client.is_tls?
    @client.auth(@password)
    register_response_callback
  end

  def connected?
    @client ||= nil
    @client.respond_to?(:is_connected?) and @client.is_connected?
  end

  def disconnect
    if @client.respond_to?(:is_connected?) && @client.is_connected?
      begin
        @client.close
      rescue Errno::EPIPE, IOError => e
        nil
      end
    end
    @client = nil
  end

  def send(msg_content)
    msg_id = SecureRandom::uuid
    encoded_payload = payload(msg_content, msg_id).to_json
    puts "payload: #{encoded_payload}"
    msg = Jabber::Message.new(@service_jid, encoded_payload)
    msg.type = :chat
    @client.send(msg)
    response = receive_response(msg_id)
    puts "response: #{response}"
    response and response["status"] == "OK"
  end

  def send_ip(ip)
    send(ip_payload(ip))
  end

  def send_sshfp(fps)
    send(sshfp_payload(fps))
  end

  def payload(req, msg_id)
    {
      version: 1,
      service: :dns,
      msgid:   msg_id,
      payload: req
    }
  end

  def ip_payload(ip)
    {
      request: ip.ipv4? ? :change_ipv4 : :change_ipv6,
      domain:  @domain,
      ip:      ip.to_s
    }
  end

  def sshfp_payload(fp)
    {
      request: :change_sshfp,
      domain:  @domain,
      sshfp:   fp
    }
  end

  def register_response_callback
    @client.add_message_callback do |msg|
      enqueue_message(JSON.parse(msg.body))
    end
  end

  def enqueue_message(msg)
    @responses << msg
  end

  def receive_response(msg_id)
    msg = @responses.pop
    return msg if (msg and (msg["msgid"] == msg_id.to_s))
    raise "failed to receive message: #{msg}"
  end
end

RESERVED_V4_NETWORKS = [
  "0.0.0.0/8",
  "10.0.0.0/8",
  "100.64.0.0/10",
  "127.0.0.0/8",
  "169.254.0.0/16",
  "172.16.0.0/12",
  "192.0.0.0/24",
  "192.0.2.0/24",
  "192.88.99.0/24",
  "192.168.0.0/16",
  "198.18.0.0/15",
  "198.51.100.0/24",
  "203.0.113.0/24",
  "224.0.0.0/4",
  "240.0.0.0/4",
  "255.255.255.255/32"
].map { |ip| IPAddr.new(ip) }

def public_ip?(ip)
  if (ip.ipv4?)
    not RESERVED_V4_NETWORKS.any? { |network| network.include? ip }
  elsif (ip.ipv6?)
    not (ip.link_local? or ip.loopback? or ip.private?)
  else
    false
  end
end

def to_ipaddr(addrinfo)
  if addrinfo.ipv4?
    IPAddr.new addrinfo.ip_address
  else
    IPAddr.new(addrinfo.ip_address.split("%")[0])
  end
end

def local_addresses
  Socket::ip_address_list.map do |addrinfo|
    to_ipaddr(addrinfo)
  end.select { |ip| public_ip?(ip) }
end

def interface_addresses(interface)
  Socket::getifaddrs.select do |ifaddr|
    ifaddr.name == interface
  end.select do |ifaddr|
    ifaddr.addr.ip? and (ifaddr.flags & Socket::IFF_MULTICAST != 0)
  end.map do |ifaddr|
    to_ipaddr(ifaddr.addr)
  end.filter do |ip|
    public_ip? ip
  end
end

def host_sshfp(keys)
  keys.flat_map { |keyfile|
    `ssh-keygen -r hostname #{keyfile}`.split("\n")
  }.map { |fp|
    fp..match(/[0-9] [0-9] [a-fA-F0-9]{32,64}$/)[0]
  }.compact
end

def hostname
  Socket.gethostname.split(".").first
end

client = XMPPClient::new(options[:domain],
                         hostname,
                         options[:server],
                         password)

success = true

begin
  client.connect

  addrs = if options[:interface]
            interface_addresses(options[:interface])
          else
            local_addresses
          end

  if options[:ipv4]
    ipv4 = addrs.find { |ip| ip.ipv4? }
    if ipv4
      puts "#{options[:server]}: #{hostname}.#{options[:domain]} IN A => #{ipv4.to_s}"
      if client.send_ip(ipv4)
        puts "OK"
      else
        puts "ERROR"
        success = false
      end
    else
      puts "#{options[:server]}: no valid public IPv4 found on the local host"
    end
  end

  if options[:ipv6]
    ipv6 = addrs.find { |ip| ip.ipv6? }
    if ipv6
      puts "#{options[:server]}: #{hostname}.#{options[:domain]} IN AAAA => #{ipv6.to_s}"
      if client.send_ip(ipv6)
        puts "OK"
      else
        puts "ERROR"
        success = false
      end
    else
      puts "#{options[:server]}: no valid public IPv6 found on the local host"
    end
  end

  if options[:sshfp]
    fps = host_sshfp(options[:sshfp])
    if not fps.empty?
      puts "#{options[:server]}: #{hostname}.#{options[:domain]} IN SSHFP => #{fps}"
      if client.send_sshfp(fps)
        puts "OK"
      else
        puts "ERROR"
        success = false
      end
    else
      puts "#{options[:server]}: no valid sshfps found"
    end
  end
ensure
  client.disconnect
end

exit success ? 0 : 1