#!/usr/bin/ruby
+# frozen_string_literal: true
+
require 'yaml'
require 'openssl'
require 'acme-client'
require 'dnsruby'
require 'time'
+require 'English'
-def read_config( path = 'config.yaml' )
+def read_config(path = 'config.yaml')
p "Reading config from #{path}"
- begin
- config = YAML.load_file( path )
- rescue Psych::SyntaxError
- $stderr.puts "Parsing configfile failed: " + $!.to_s
- raise
- rescue Errno::ENOENT
- $stderr.puts "IO failed: " + $!.to_s
- raise
+ YAML.load_file(path)
+rescue Psych::SyntaxError => e
+ warn "Parsing configfile failed: #{e}"
+ raise
+rescue Errno::ENOENT => e
+ warn "IO failed: #{e}"
+ raise
+end
+
+def ensure_cert_dir(path = './certs')
+ unless File.exist?(path)
+ puts 'Certificate directory does not exist. Creating with secure permissions.'
+ Dir.mkdir(path, 0o0700)
end
- return config
+ File.world_writable?(path) && warn('WARNING! Certificate directory is world writable! This could be a serious security issue!')
+ File.world_readable?(path) && warn('WARNING! Certificate directory is world readable! This could be a serious security issue!')
+ File.file?(path) && raise('Certificate directory is not a directory but a file. Aborting.')
+ File.writable?(path) || raise('Certificate directory is not writable. Aborting.')
end
-def read_account_key( path = 'pkey.pem' )
+def read_account_key(path = 'pkey.pem')
p "Reading account key from #{path}"
- if File.readable?( path )
+ if File.readable?(path)
p "File #{path} is readable, trying to parse"
- privatekey_string = File.read( path )
- private_key = OpenSSL::PKey::EC.new( privatekey_string )
+ privatekey_string = File.read(path)
+ private_key = OpenSSL::PKey::EC.new(privatekey_string)
+ elsif File.exist?(path)
+ raise("The file #{path} exists but is not readable. Make it readable or specify different path")
else
- if File.exists?( path )
- raise( "The file #{path} exists but is not readable. Make it readable or specify different path" )
- else
- p "File #{path} does not exist, trying to create"
- private_key = OpenSSL::PKey::EC.generate( "prime256v1" )
- File.write( path, private_key.private_to_pem )
- end
+ p "File #{path} does not exist, trying to create"
+ private_key = OpenSSL::PKey::EC.generate('prime256v1')
+ File.write(path, private_key.private_to_pem)
end
return private_key
end
-def read_cert_key( domain )
- folder = "./certs/#{domain}/"
+def read_cert_key(cert_name)
+ folder = "./certs/#{cert_name}/"
path = "#{folder}/current.key"
- p "Reading cert key from #{path}"
- if File.readable?( path )
- p "File #{path} is readable, trying to parse"
- privatekey_string = File.read( path )
- private_key = OpenSSL::PKey::EC.new( privatekey_string )
+ p "cert_name #{cert_name}: Reading cert key from #{path}"
+ if File.readable?(path)
+ p "cert_name #{cert_name}: File #{path} is readable, trying to parse"
+ privatekey_string = File.read(path)
+ private_key = OpenSSL::PKey::EC.new(privatekey_string)
+ elsif File.exist?(path)
+ raise("cert_name #{cert_name}: The file #{path} exists but is not readable. Make it readable or specify different path")
else
- if File.exists?( path )
- raise( "The file #{path} exists but is not readable. Make it readable or specify different path" )
- else
- p "File #{path} does not exist, trying to create"
- private_key = OpenSSL::PKey::EC.generate( "prime256v1" )
- pkey_file = File.new( folder + Time.now.to_i.to_s + ".key", 'w' )
- pkey_file.write( private_key.private_to_pem )
- File.symlink( File.basename( pkey_file ), File.dirname( pkey_file ) + "/current.key" )
- end
+ p "cert_name #{cert_name}: File #{path} does not exist, trying to create"
+ private_key = OpenSSL::PKey::EC.generate('prime256v1')
+ pkey_file = File.new("#{folder}#{Time.now.to_i}.key", 'w')
+ pkey_file.write(private_key.private_to_pem)
+ File.symlink(File.basename(pkey_file), "#{File.dirname(pkey_file)}/current.key")
end
return private_key
end
-def deploy_dns01_challenge_token( domain, challenge, nameserver, config )
- p 'Creating DNS UPDATE packet'
- update = Dnsruby::Update.new( domain )
+def lookup_soa(domain)
+ rec = Dnsruby::Recursor.new
+ p "Domain #{domain}: Getting SOA records for #{domain}"
+ rec.query_no_validation_or_recursion(domain, 'SOA')
+rescue StandardError => e
+ warn "Domain #{domain}: SOA lookup during deploy failed: #{e}"
+ raise
+end
+
+def find_apex_domain(domain)
+ domain_soa_resp = lookup_soa(domain)
+ if domain_soa_resp.answer.empty?
+ domain_soa_resp.authority[0].name
+ else
+ domain_soa_resp.answer[0].name
+ end
+end
+
+def deploy_dns01_challenge_token(domain, challenge, nameserver, config)
+ p "Domain #{domain}: Creating DNS UPDATE packet"
+
+ apex_domain = find_apex_domain(domain)
+
+ update = Dnsruby::Update.new(apex_domain)
# TODO: delete challenge token record after validation
- update.delete( challenge.record_name + "." + domain, challenge.record_type )
- update.add( challenge.record_name + "." + domain, challenge.record_type, 10, challenge.record_content )
+ update.delete("#{challenge.record_name}.#{domain}", challenge.record_type)
+ update.add("#{challenge.record_name}.#{domain}", challenge.record_type, 10, challenge.record_content)
- p 'Creating object for contacting nameserver'
- res = Dnsruby::Resolver.new( nameserver )
+ p "Domain #{domain}: Creating object for contacting nameserver"
+ res = Dnsruby::Resolver.new(nameserver)
res.dnssec = false
- p 'Looking up TSIG parameters'
- tsig_name = config['domains'][domain]['tsig_key']
- tsig_key = config['tsig_keys'][tsig_name]['key']
- tsig_alg = config['tsig_keys'][tsig_name]['algorithm']
+ p "Domain #{domain}: Looking up TSIG parameters"
+ tsig_name = config.dig('domains', domain, 'tsig_key') || config.dig('defaults', 'domains', 'tsig_key')
+ tsig_key = config.dig('tsig_keys', tsig_name, 'key')
+ tsig_alg = config.dig('tsig_keys', tsig_name, 'algorithm')
- p 'Creating TSIG object'
+ p "Domain #{domain}: Creating TSIG object"
tsig = Dnsruby::RR.create(
{
name: tsig_name,
}
)
- p 'Signing DNS UPDATE packet with TSIG object'
+ p "Domain #{domain}: Signing DNS UPDATE packet with TSIG object"
tsig.apply(update)
- p 'Sending UPDATE to nameserver'
- response = res.send_message(update)
+ p "Domain #{domain}: Sending UPDATE to nameserver"
+ res.send_message(update)
+rescue StandardError => e
+ warn "Domain #{domain}: DNS Update failed: #{e}"
+ raise
end
-def wait_for_challenge_propagation( domain, challenge )
- p 'Creating recursor object for checking challenge propagation'
+def wait_for_challenge_propagation(domain, challenge)
rec = Dnsruby::Recursor.new
- p "Getting NS records for #{domain}"
- domain_auth_ns = rec.query_no_validation_or_recursion( domain, "NS" )
+ p "Domain #{domain}: Getting SOA records for #{domain}"
+ begin
+ domain_soa_resp = rec.query_no_validation_or_recursion(domain, 'SOA')
+ rescue StandardError => e
+ warn "Domain #{domain}: SOA lookup during propagation wait failed: #{e}"
+ raise
+ end
+ apex_domain = if domain_soa_resp.answer.empty?
+ domain_soa_resp.authority[0].name
+ else
+ domain_soa_resp.answer[0].name
+ end
+
+ p "Domain #{domain}: Creating recursor object for checking challenge propagation"
+ rec = Dnsruby::Recursor.new
+ p "Domain #{domain}: Getting NS records for #{apex_domain}"
+ begin
+ domain_auth_ns = rec.query_no_validation_or_recursion(apex_domain, 'NS')
+ rescue StandardError => e
+ warn "Domain #{domain}: NS lookup failed: #{e}"
+ raise
+ end
+
+ p "Domain #{domain}: Checking challenge status on all NS"
+
+ threads = []
- p 'Checking challenge status on all NS'
domain_auth_ns.answer.each do |ns|
- nameserver = ns.rdata.to_s
- p "Creating resolver object for checking propagation on #{nameserver}"
- res = Dnsruby::Resolver.new( nameserver )
- res.dnssec = false
- res.do_caching = false
- begin
- p 'Querying ACME challenge record'
- result = res.query_no_validation_or_recursion( "_acme-challenge." + domain, "TXT" )
- p result
- propagated = result.answer.any? do |answer|
- answer.rdata[0] == challenge.record_content
- end
- unless propagated
- p 'Not yet propagated, sleeping before checking again'
- sleep(1)
+ threads << Thread.new(ns) do |my_ns|
+ nameserver = my_ns.rdata.to_s
+ p "Domain #{domain}: Creating resolver object for checking propagation on #{nameserver}"
+ res = Dnsruby::Resolver.new(nameserver)
+ res.dnssec = false
+ res.do_caching = false
+ loop do
+ p "Domain #{domain}: Querying ACME challenge record"
+ result = res.query_no_validation_or_recursion("_acme-challenge.#{domain}", 'TXT')
+ propagated = result.answer.any? do |answer|
+ answer.rdata[0] == challenge.record_content
+ end
+ break if propagated
+
+ p "Domain #{domain}: Not yet propagated, still old value, sleeping before checking again"
+ sleep(0.5)
+ rescue Dnsruby::NXDomain
+ p "Domain #{domain}: Not yet propagated, NXdomain, sleeping before checking again"
+ sleep(0.5)
+ retry
+ rescue StandardError => e
+ warn "Domain #{domain}: ACME challenge lookup failed: #{e}"
+ raise
end
- end until propagated
+ end
end
+
+ threads.each(&:join)
end
-def wait_for_challenge_validation( challenge )
+def wait_for_challenge_validation(challenge, cert_name)
p 'Requesting validation of challenge'
- challenge.request_validation
+ begin
+ retries ||= 0
+ challenge.request_validation
+ rescue Acme::Client::Error::BadNonce
+ retries += 1
+ p 'Retrying because of invalid nonce.'
+ retry if retries <= 5
+ end
while challenge.status == 'pending'
- p 'Sleeping because challenge validation is pending'
- sleep(1)
+ p "Cert #{cert_name}: Sleeping because challenge validation is pending"
+ sleep(0.1)
p 'Checking again'
- challenge.reload
+ begin
+ retries ||= 0
+ challenge.reload
+ rescue Acme::Client::Error::BadNonce
+ retries += 1
+ p 'Retrying because of invalid nonce.'
+ retry if retries <= 5
+ end
end
end
-def get_cert( order, domains, domain_key )
- path = "./certs/#{domains[0]}/"
+def get_cert(order, cert_name, domains, domain_key)
+ path = "./certs/#{cert_name}/"
crt_file = "#{path}/cert.pem"
- p 'Creating CSR object'
+ p "Cert #{cert_name}: Creating CSR object"
csr = Acme::Client::CertificateRequest.new(
private_key: domain_key,
names: domains,
- subject: { common_name: "#{domains[0]}" }
+ subject: { common_name: domains[0] }
)
- p 'Finalize cert order'
- order.finalize(csr: csr)
- while order.status == 'processing'
- p 'Sleep while order is processing'
- sleep(1)
- p 'Rechecking order status'
+ p "Cert #{cert_name}: Finalize cert order"
+ begin
+ retries ||= 0
order.reload
+ rescue Acme::Client::Error::BadNonce
+ retries += 1
+ p 'Retrying because of invalid nonce.'
+ retry if retries <= 5
end
- cert = order.certificate
-
- p 'Writing cert'
- cert_file = File.new( path + Time.now.to_i.to_s + ".crt", 'w' )
- cert_file.write( cert )
- if File.symlink?( File.dirname( cert_file ) + "/current.crt" )
- File.unlink( File.dirname( cert_file ) + "/current.crt" )
- File.symlink( File.basename( cert_file ), File.dirname( cert_file ) + "/current.crt" )
- else
- raise StandardError
+ begin
+ retries ||= 0
+ order.finalize(csr: csr)
+ rescue Acme::Client::Error::BadNonce
+ retries += 1
+ p 'Retrying because of invalid nonce.'
+ retry if retries <= 5
+ end
+ while order.status == 'processing'
+ p "Cert #{cert_name}: Sleep while order is processing"
+ sleep(0.1)
+ p "Cert #{cert_name}: Rechecking order status"
+ begin
+ retries ||= 0
+ order.reload
+ rescue Acme::Client::Error::BadNonce
+ retries += 1
+ p 'Retrying because of invalid nonce.'
+ retry if retries <= 5
+ end
+ end
+ # p "order status: #{order.status}"
+ # pp order
+ begin
+ retries ||= 0
+ cert = order.certificate
+ rescue Acme::Client::Error::BadNonce
+ retries += 1
+ p 'Retrying because of invalid nonce.'
+ retry if retries <= 5
+ end
+
+ p "Cert #{cert_name}: Writing cert"
+ cert_file = File.new("#{path}#{Time.now.to_i}.crt", 'w')
+ cert_file.write(cert)
+ if File.symlink?("#{File.dirname(cert_file)}/current.crt")
+ File.unlink("#{File.dirname(cert_file)}/current.crt")
+ File.symlink(File.basename(cert_file), "#{File.dirname(cert_file)}/current.crt")
+ elsif File.file?("#{File.dirname(cert_file)}/current.crt")
+ raise 'Could not place symlink for "current.crt" because that is already a normal file.'
end
return cert
end
config = read_config
+cert_dir = config.dig('global', 'cert_dir') || './certs/'
+
+ensure_cert_dir(cert_dir)
+
+acme_threads = []
# iterate over configured certs
# TODO: make this one thread per cert
+# TODO: check all domains for apex domain, deploy challenges for one apex_domain all at once
config['certs'].each_pair do |cert_name, cert_opts|
- p "Finding CA to use for cert #{cert_name}"
- acme_directory_url = config['CAs'][cert_opts['ca']['name']]['directory_url']
+ acme_threads << Thread.new(cert_name, cert_opts) do |cert_name, cert_opts|
+ ensure_cert_dir(cert_dir + cert_name)
- p "Finding account to use for cert #{cert_name} from CA #{cert_opts['ca']['name']}"
- account = config['ca_accounts'][cert_opts['ca']['account']]
- email = account['email']
+ p "Cert #{cert_name}: Finding CA to use for cert"
+ cert_ca = cert_opts['ca'] || config.dig('defaults', 'certs', 'ca')
+ cert_ca_name = cert_ca['name']
+ cert_ca_account = cert_ca['account']
- private_key = read_account_key( account['keyfile'] )
+ p "Cert #{cert_name}: Finding directory URL for CA"
+ acme_directory_url = config.dig('CAs', cert_ca_name, 'directory_url')
- p 'Creating client object for communication with CA'
- client = Acme::Client.new( private_key: private_key, directory: acme_directory_url )
+ p "Cert #{cert_name}: Finding account to use for cert #{cert_name} from CA #{cert_ca_name}"
+ account = config.dig('ca_accounts', cert_ca_account)
+ email = account['email']
- client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true)
+ private_key = read_account_key(account['keyfile'])
- p "Creating order object for cert #{cert_name}"
- order = client.new_order(identifiers: cert_opts['domain_names'] )
- if order.status != 'ready'
- p 'Order is not ready, we need to authorize first'
+ p "Cert #{cert_name}: Creating client object for communication with CA"
+ client = Acme::Client.new(private_key: private_key, directory: acme_directory_url)
- p 'Iterating over required authorizations'
- order.authorizations.each do |auth|
- p "Processing authorization for #{auth.domain}"
- p "Finding challenge type for #{auth.domain}"
- challenge = auth.dns01
- deploy_dns01_challenge_token( auth.domain, challenge, config['domains'][auth.domain]['primary_ns'], config )
- wait_for_challenge_propagation( auth.domain, challenge )
- wait_for_challenge_validation( challenge )
+ begin
+ retries ||= 0
+ client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true)
+ rescue Acme::Client::Error::BadNonce
+ retries += 1
+ p 'Retrying because of invalid nonce.'
+ retry if retries <= 5
end
- else
- p 'Order is ready, we don’t need to authorize'
- end
- domain_key = read_cert_key( cert_opts['domain_names'][0] )
- get_cert( order, cert_opts['domain_names'], domain_key )
+ p "Cert #{cert_name}: Creating order object for cert #{cert_name}"
+ begin
+ retries ||= 0
+ order = client.new_order(identifiers: cert_opts['domain_names'])
+ rescue Acme::Client::Error::BadNonce
+ retries += 1
+ p 'Retrying because of invalid nonce.'
+ retry if retries <= 5
+ end
+
+ p "Cert #{cert_name}: order status"
+ p order.status
+
+ if order.status != 'ready'
+ p "Cert #{cert_name}: Order is not ready, we need to authorize first"
+
+ # TODO: collect dns modifications per primary NS, update all at once
+ p "Cert #{cert_name}: Iterating over required authorizations"
+ begin
+ retries ||= 0
+ auths = order.authorizations
+ rescue Acme::Client::Error::BadNonce
+ retries += 1
+ p 'Retrying because of invalid nonce.'
+ retry if retries <= 5
+ end
+ auths.each do |auth|
+ p "Cert #{cert_name}: Processing authorization for #{auth.domain}"
+ p "Cert #{cert_name}: Finding challenge type for #{auth.domain}"
+ # p "Cert #{cert_name}: auth is:"
+ # pp auth
+ if auth.status == 'valid'
+ p "Cert #{cert_name}: Authorization for #{auth.domain} is still valid, skipping"
+ next
+ end
+
+ challenge = auth.dns01
+ primary_ns = config.dig('domains', auth.domain, 'primary_ns') || config.dig('defaults', 'domains', 'primary_ns')
+ deploy_dns01_challenge_token(auth.domain, challenge, primary_ns, config)
+ wait_for_challenge_propagation(auth.domain, challenge)
+ wait_for_challenge_validation(challenge, cert_name)
+ end
+ else
+ p "Cert #{cert_name}: Order is ready, we don’t need to authorize"
+ end
+ domain_key = read_cert_key(cert_name)
+
+ get_cert(order, cert_name, cert_opts['domain_names'], domain_key)
+ end
end
+
+acme_threads.each(&:join)