Skip to content

Instantly share code, notes, and snippets.

@robbat2
Created April 23, 2019 06:29
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save robbat2/2fe97c09a6c5bb8ca85064e0d87f8126 to your computer and use it in GitHub Desktop.
Save robbat2/2fe97c09a6c5bb8ca85064e0d87f8126 to your computer and use it in GitHub Desktop.
#!/usr/bin/ruby
# Gentoo Infrastructure custom LetsEncrypt client
# Copyright 2019 Robin H. Johnson <robbat2@gentoo.org>
#
# TODO: fails on GeoDNS DNS still
#
# Runs ACME new_order cycle, and completes DNS challenges
# - uses RFC2316 (nsupdate protocol)
# - does direct TXT or DNS alias/CNAME TXT
# - runs all authorizations ONLY
# - Certificates come later in a split out tool
# stdlib
require 'openssl'
require 'optparse'
require 'pp'
# additional
require 'acme-client'
require 'dnsruby'
ENDPOINT_LE_PRODUCTION = 'https://acme-v02.api.letsencrypt.org/'
ENDPOINT_LE_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/'
options = {
acme_endpoint: nil,
acme_keyfile: nil,
acme_kid: nil,
dns_update_port: nil,
dns_query_port: nil,
dns_update_server: nil,
dns_query_server: nil,
dns_update_keyfile: nil,
dns_query_keyfile: nil,
dns_chase_cname: false
}
BANNER = 'Usage: acme-authorize.rb [options]'
MANDATORY_OPTS = {
:acme_endpoint => ["--acme-endpoint=ENDPOINT_URI", "ACME endpoint", String],
:acme_keyfile => ["--acme-keyfile=KEY_FILENAME", "ACME key", String],
:csr => ["--csr=CSR_FILE", "CSR", String],
:dns_update_port => ["--dns-update-port=PORT", "DNS server: port", OptionParser::DecimalInteger],
:dns_update_server => ["--dns-update-server=SERVER", "DNS server: host or IP", String],
:dns_update_zone => ["--dns-update-zone=ZONE", "DNS zone", String],
}
OPTIONAL_OPTS = {
:acme_kid => ["--acme-kid=KID", "ACME key", OptionParser::DecimalInteger],
:dns_query_port => ["--dns-query-port=PORT", "DNS server: port", OptionParser::DecimalInteger],
:dns_query_server => ["--dns-query-server=SERVER", "DNS server: host or IP", String],
:dns_query_keyfile => ["--dns-query-keyfile=KEY_FILENAME", "DNS key: TSIG key", String],
:dns_query_keyname => ["--dns-query-keyname=KEY_NAME", "DNS key: Name of TSIG key in multi-key file", String],
:dns_update_keyfile => ["--dns-update-keyfile=KEY_FILENAME", "DNS key: TSIG key", String],
:dns_update_keyname => ["--dns-query-keyname=KEY_NAME", "DNS key: Name of TSIG key in multi-key file", String],
:acme_endpoint_directory => ["--acme-endpoint-directory=ENDPOINT_URI", "ACME endpoint: directory", String],
:acme_endpoint_kid => ["--acme-endpoint-kid=ENDPOINT_URI", "ACME endpoint: kid", String],
}
OptionParser.new do |opts|
opts.banner = BANNER
[MANDATORY_OPTS, OPTIONAL_OPTS].each do |h|
h.each_pair do |k,v|
opts.on(*v) { |vv| options[k] = vv }
end
end
opts.on("--le-production", "Shortcut for --endpoint=#{ENDPOINT_LE_PRODUCTION}", String) { |s| options[:acme_endpoint] = ENDPOINT_LE_PRODUCTION }
opts.on("--le-staging", "Shortcut for --endpoint=#{ENDPOINT_LE_STAGING}", String) { |s| options[:acme_endpoint] = ENDPOINT_LE_STAGING }
opts.on("--dns-chase-cname", "DNS: chase CNAME for DNS alias", TrueClass) { |v| options[:dns_chase_cname] = v }
end.parse!
raise OptionParser::AmbiguousOption.new("--acme-kid and --acme-endpoint-kid are mutually exclusive") if options[:acme_kid] and options[:acme_endpoint_kid]
options[:dns_update_port] = 53 unless options.dig(:dns_update_port)
options[:dns_query_port] = options[:dns_update_port] unless options.dig(:dns_query_port)
options[:acme_endpoint_directory] = [options[:acme_endpoint], 'directory'].join('/') unless options[:acme_endpoint_directory]
options[:acme_endpoint_kid] = [options[:acme_endpoint], 'acme', 'acct', options[:acme_kid].to_s].join('/') if options[:acme_kid]
MANDATORY_OPTS.keys.each do |k|
raise OptionParser::MissingArgument.new(MANDATORY_OPTS[k][0]) unless options.dig(k)
end
#pp options
SAN_DNS_PREFIX = 'DNS:'
DEFAULT_TSIG_ALG = 'hmac-md5'
def extract_keys(filename)
tsig_name = tsig_secret = tsig_alg = nil
in_keyblock = false
file_content = File.readlines(filename).map do |s|
# Remove multi-line comments FIRST
# negative lookahead regex!
s.gsub!(%r{/\*(?!.*\*/)?\*/}m,'')
# Remove then single-line with '#' or '//'
s.gsub!(%r{(//|#).*$}, '')
s.chomp
end.join(" ")
keys = {}
# \k = named backreference regex!
file_content.scan(/\bkey\s+(?<keyprefix>"?)(?<keyname>[-\w]+)(\k<keyprefix>)\s*{(?<keydef>[^}]+)}/) do |m|
keydata = { :algorithm => DEFAULT_TSIG_ALG }
# Sadly scan does not preserve named groups
# \2 = numeric backreference
m[2].scan(/\s*([-\w]+)\s*("?)([^;]+)(\2)\s*;/) { |k, _, v| keydata[k.to_sym] = v }
keys[m[1]] = keydata
end
keys
end
def create_tsig_rr(name, alg, secret, fudge = 30)
return Dnsruby::RR.create({
:name => name,
:type => 'TSIG',
:ttl => 0,
:klass => 'ANY',
:key => secret,
:algorithm => alg,
:fudge => fudge,
:error => 0,
})
end
def get_tsig_rr(tsig_keys, keyname, fudge = 30)
#pp "keyname=#{keyname.inspect}"
#pp "keys=#{tsig_keys.inspect}"
#pp "key=#{tsig_keys[keyname].inspect}"
create_tsig_rr(keyname, tsig_keys[keyname][:algorithm], tsig_keys[keyname][:secret], fudge)
end
options[:dns_update_keys] = options[:dns_update_keyfile] ? extract_keys(options[:dns_update_keyfile]) : []
options[:dns_query_keys] = options[:dns_query_keyfile] ? extract_keys(options[:dns_query_keyfile]) : []
raise OptionParser::AmbiguousOption.new("--dns-update-keyname required due to multiple keys") if options[:dns_update_keys].length > 1
raise OptionParser::AmbiguousOption.new("--dns-query-keyname required due to multiple keys") if options[:dns_query_keys].length > 1
raise OptionParser::AmbiguousOption.new("--dns-update-keyfile contained no keys") if options[:dns_update_keyfile] and options[:dns_update_keys].length == 0
raise OptionParser::AmbiguousOption.new("--dns-query-keyfile contained no keys") if options[:dns_query_keyfile] and options[:dns_query_keys].length == 0
options[:dns_update_keyname] = options[:dns_update_keys].keys.first if options[:dns_update_keyname].nil? and options[:dns_update_keys].length == 1
options[:dns_query_keyname] = options[:dns_query_keys].keys.first if options[:dns_query_keyname].nil? and options[:dns_query_keys].length == 1
def csr_to_hostnames(openssl_x509_request)
raise ArgumentError unless openssl_x509_request.is_a?(OpenSSL::X509::Request)
subj_raw = openssl_x509_request.subject.to_a
# Add basic hostnames
hostnames = subj_raw.select{ |a| a[0] == 'CN' }.map { |a| a[1] }
# Extra x509 extensions
extReq = openssl_x509_request.attributes.select do |x|
x.oid == 'extReq'
end.first.value.value.first.value.collect do |asn1ext|
OpenSSL::X509::Extension.new(asn1ext).to_a
end
# Extract SAN/subjectAltName
altnames = extReq.select { |a| a[0] == 'subjectAltName' }.first || nil
if altnames then
hostnames.concat(altnames[1].split(/, /).map do |a|
raise "#{a} does not start with #{SAN_DNS_PREFIX}" unless a.start_with?(SAN_DNS_PREFIX)
a[(SAN_DNS_PREFIX.length)..-1]
end)
end
hostnames.uniq
end
#pp options
resolver_update = Dnsruby::Resolver.new(:nameserver => options[:dns_update_server], :port => options[:dns_update_port])
resolver_update.tsig = get_tsig_rr(options[:dns_update_keys], options[:dns_update_keyname]) if options[:dns_update_keyname]
resolver_update.dnssec = false
resolver_config = {}
resolver_config.merge!({nameserver: options[:dns_query_server], port: options[:dns_query_port]}) if options[:dns_query_server]
resolver_query = Dnsruby::Resolver.new(resolver_config)
resolver_query.tsig = get_tsig_rr(options[:dns_query_keys], options[:dns_query_keyname]) if options[:dns_query_keyname] and options[:dns_query_server]
resolver_query.dnssec = true
#resolver_query = Dnsruby::Recursor.new(resolver_query)
acme_pkey_data = File.read(options[:acme_keyfile])
acme_pkey = nil
[OpenSSL::PKey::EC, OpenSSL::PKey::RSA].each do |klass|
begin
acme_pkey = klass.new(acme_pkey_data)
rescue
end
end
raise "Unable to load ACME client key from #{options[:acme_keyfile]}" unless acme_pkey
# kid is not mandatory, but costs an extra API call
client = Acme::Client.new(private_key: acme_pkey, directory: options[:acme_endpoint_directory], kid: options[:acme_endpoint_kid])
csr = OpenSSL::X509::Request.new File.read options[:csr]
hostnames = csr_to_hostnames(csr)
#pp hostnames
order = client.new_order(identifiers: hostnames)
order.authorizations.each do |authorization|
challenge = authorization.dns
next if challenge.status == 'valid'
print "Authorizing #{authorization.domain}\n"
challenge_fqdn = challenge.record_name + '.' + authorization.domain
challenge_hostname = challenge_fqdn
#pp [challenge_hostname, challenge.record_type, challenge.record_content]
if options[:dns_chase_cname] then
ret, error = resolver_query.query!(challenge_fqdn, 'CNAME')
if error
print "Could not chase CNAME #{challenge_fqdn}: #{error}\n"
next
elsif ret.answer.length == 0 then
pp ret
print "Could not chase CNAME #{challenge_fqdn}: no results found\n"
next
end
#print "TSIG response was verified? : #{ret.verified?}\n"
#pp ret
challenge_cname = ret.answer[0].rdata
challenge_hostname = challenge_cname
#pp [challenge_hostname, challenge.record_type, challenge.record_content]
end
#
update = Dnsruby::Update.new(options[:dns_update_zone])
dns_update = [challenge_hostname, challenge.record_type, 1, challenge.record_content]
update.add(*dns_update)
#pp update
response, error = resolver_update.send_message!(update)
if error
print "Failed DNS update: #{dns_update.to_s}: #{error}}"
next
end
#print "TSIG response was verified? : #{response.verified?}\n"
found = false
i = 10
while i > 0 do
ret, error = resolver_query.query!(challenge_hostname, 'TXT')
if error.nil? then
found = true if ret.answer.any? { |a| a.type == 'TXT' and a.strings.include? challenge.record_content }
break if found
end
Kernel.sleep(1)
i -= 1
end
if not found then
print "Challenge not found in DNS\n"
next
end
challenge.request_validation
while challenge.status == 'pending'
#pp challenge.status # => 'valid'
print "."
Kernel.sleep(1)
challenge.reload
end
if challenge.status == 'valid'
print " PASS\n"
elsif challenge.status == 'pending'
print " PENDING\n"
elsif challenge.status == 'failed'
print " FAIL\n"
else
print "UNKNOWN #{challenge.status}\n"
end
end
# vim:ft=ruby sts=2 ts=2 sw=2:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment