#!/usr/bin/ruby require 'yaml' require 'openssl' require 'acme-client' require 'dnsruby' require 'time' 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 end return config end def read_account_key( path = 'pkey.pem' ) p "Reading account 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 ) 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 end return private_key end def read_cert_key( domain ) folder = "./certs/#{domain}/" 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 ) 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 end return private_key end def deploy_dns01_challenge_token( domain, challenge, nameserver, config ) p 'Creating DNS UPDATE packet' update = Dnsruby::Update.new( 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 ) p '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 'Creating TSIG object' tsig = Dnsruby::RR.create( { name: tsig_name, type: 'TSIG', key: tsig_key, algorithm: tsig_alg, } ) p 'Signing DNS UPDATE packet with TSIG object' tsig.apply(update) p 'Sending UPDATE to nameserver' response = res.send_message(update) end def wait_for_challenge_propagation( domain, challenge ) p 'Creating recursor object for checking challenge propagation' rec = Dnsruby::Recursor.new p "Getting NS records for #{domain}" domain_auth_ns = rec.query_no_validation_or_recursion( domain, "NS" ) 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) end end until propagated end end def wait_for_challenge_validation( challenge ) p 'Requesting validation of challenge' challenge.request_validation while challenge.status == 'pending' p 'Sleeping because challenge validation is pending' sleep(1) p 'Checking again' challenge.reload end end def get_cert( order, domains, domain_key ) path = "./certs/#{domains[0]}/" crt_file = "#{path}/cert.pem" p 'Creating CSR object' csr = Acme::Client::CertificateRequest.new( private_key: domain_key, names: domains, 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' order.reload 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 end return cert end config = read_config # iterate over configured certs # TODO: make this one thread per cert 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'] 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'] private_key = read_account_key( account['keyfile'] ) p 'Creating client object for communication with CA' client = Acme::Client.new( private_key: private_key, directory: acme_directory_url ) client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true) 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 '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 ) 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 ) end