Instantly share code, notes, and snippets.
Created
April 23, 2019 06:29
-
Save robbat2/2fe97c09a6c5bb8ca85064e0d87f8126 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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