285 lines
6.2 KiB
Ruby
285 lines
6.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'ipaddr'
|
|
require 'socket'
|
|
require 'optparse'
|
|
require 'json'
|
|
require 'securerandom'
|
|
require 'xmpp4r'
|
|
|
|
options = {
|
|
sshfp: []
|
|
}
|
|
|
|
# rubocop:disable Metrics/BlockLength
|
|
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', '--ssh-fp=SSHFP', 'SSH fingerprint to register with he backplane.') do |sshfp|
|
|
options[:sshfp] << sshfp
|
|
end
|
|
end.parse!
|
|
# rubocop:enable Metrics/BlockLength
|
|
|
|
raise 'domain is required' unless options[:domain]
|
|
raise 'server is required' unless options[:server]
|
|
raise 'password file is required' unless options[:pw_file]
|
|
raise 'at least one of -4 or -6 required' unless options[:ipv4] || options[:ipv6]
|
|
|
|
password = options[:pw_file]
|
|
raise "file does not exist or is not readable: #{password}" unless File::readable?(password)
|
|
|
|
def error(msg)
|
|
puts msg
|
|
raise msg
|
|
end
|
|
|
|
# XMPP client for Fudo Backplane
|
|
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') unless @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
|
|
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 && 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(fingerprint)
|
|
{
|
|
request: :change_sshfp,
|
|
domain: @domain,
|
|
sshfp: fingerprint
|
|
}
|
|
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 && 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?
|
|
RESERVED_V4_NETWORKS.none? { |network| network.include? ip }
|
|
elsif ip.ipv6?
|
|
!(ip.link_local? || ip.loopback? || 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
|
|
ips = Socket::ip_address_list.map do |addrinfo|
|
|
to_ipaddr(addrinfo)
|
|
end
|
|
ips.select { |ip| public_ip?(ip) }
|
|
end
|
|
|
|
def interface_addresses(interface)
|
|
ifaddrs = Socket::getifaddrs.select do |ifaddr|
|
|
ifaddr.name == interface &&
|
|
ifaddr.addr.ip? &&
|
|
ifaddr.flag & Socket::IFF_MULTICAST != 0
|
|
end
|
|
ifaddrs.map { |ifaddr| to_ipaddr(ifaddr.addr) }.filter(:public_ip?)
|
|
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(:ipv4?)
|
|
if ipv4
|
|
puts "#{options[:server]}: #{hostname}.#{options[:domain]} IN A => #{ipv4}"
|
|
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(:ipv6?)
|
|
if ipv6
|
|
puts "#{options[:server]}: #{hostname}.#{options[:domain]} IN AAAA => #{ipv6}"
|
|
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
|
|
|
|
unless options[:sshfp].empty?
|
|
fps = options[:sshfp]
|
|
puts "#{options[:server]}: #{hostname}.#{options[:domain]} IN SSHFP => #{fps}"
|
|
if client.send_sshfp(fps)
|
|
puts 'OK'
|
|
else
|
|
puts 'ERROR'
|
|
success = false
|
|
end
|
|
end
|
|
ensure
|
|
client.disconnect
|
|
end
|
|
|
|
exit success ? 0 : 1
|
|
|