commit 7b29ab82b124b52e24d56c67c3687a5958ac88dd Author: Niten Date: Thu Aug 26 11:13:59 2021 -0700 Initial checkin diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..7b60ce6 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gem "xmpp4r" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..90e2a52 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,13 @@ +GEM + remote: https://rubygems.org/ + specs: + xmpp4r (0.5.6) + +PLATFORMS + ruby + +DEPENDENCIES + xmpp4r + +BUNDLED WITH + 1.17.2 diff --git a/dns-client.rb b/dns-client.rb new file mode 100644 index 0000000..11a4503 --- /dev/null +++ b/dns-client.rb @@ -0,0 +1,297 @@ + +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 diff --git a/gemset.nix b/gemset.nix new file mode 100644 index 0000000..fc227aa --- /dev/null +++ b/gemset.nix @@ -0,0 +1,12 @@ +{ + xmpp4r = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "15ls2yqjvflxrc8chv5pcdh2p1p9fjsky74yc8y7wvw90wz0izrb"; + type = "gem"; + }; + version = "0.5.6"; + }; +} \ No newline at end of file