Client to communicate with the Fudo backplane.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

297 lines
6.5 KiB

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