#!/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') p "Reading config from #{path}" 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 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') 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) elsif File.exist?(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 return private_key end def read_cert_key(cert_name) folder = "./certs/#{cert_name}/" path = "#{folder}/current.key" 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 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 lookup_ns(domain) p "Domain #{domain}: Creating recursor object for checking challenge propagation" rec = Dnsruby::Resolver.new p "Domain #{domain}: Getting NS records for #{domain}" rec.query_no_validation_or_recursion(domain, 'NS') rescue StandardError => e warn "Domain #{domain}: NS lookup during deploy failed: #{e}" raise end def lookup_soa(domain) rec = Dnsruby::Resolver.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 build_dns_update_packet(apex, authzs) update = Dnsruby::Update.new(apex) authzs.each do |auth| chal = auth.dns01 update.delete("#{chal.record_name}.#{auth.domain}", chal.record_type) update.add("#{chal.record_name}.#{auth.domain}", chal.record_type, 3, chal.record_content) end return update end def build_tsig_object(apex, config) p "Domain #{apex}: Looking up TSIG parameters" tsig_name = config.dig('domains', apex, '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 "Domain #{apex}: Creating TSIG object" tsig = Dnsruby::RR.create( { name: tsig_name, type: 'TSIG', key: tsig_key, algorithm: tsig_alg, } ) end def deploy_dns_tokens_on_apex(apex, authzs, nameserver, config) update_packet = build_dns_update_packet(apex, authzs) p "Domain #{apex}: Creating object for contacting nameserver" res = Dnsruby::Resolver.new(nameserver) res.dnssec = false tsig = build_tsig_object(apex, config) p "Domain #{apex}: Signing DNS UPDATE packet with TSIG object" tsig.apply(update_packet) p "Domain #{apex}: Sending UPDATE to nameserver" res.send_message(update_packet) rescue StandardError => e warn "Domain #{apex}: DNS Update failed: #{e}" raise 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) p "Domain #{domain}: Creating object for contacting nameserver" res = Dnsruby::Resolver.new(nameserver) res.dnssec = false tsig = build_tsig_object(domain, config) p "Domain #{domain}: Signing DNS UPDATE packet with TSIG object" tsig.apply(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) apex_domain = find_apex_domain(domain) domain_auth_ns = lookup_ns(apex_domain) p "Domain #{domain}: Checking challenge status on all NS" threads = [] domain_auth_ns.answer.each do |ns| 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 end threads.each(&:join) end def acme_request_with_retries(retries: 5, &block) # p "Retries: #{retries}" block.call(self) rescue Acme::Client::Error::BadNonce raise unless retries.positive? p 'Retrying because of invalid nonce.' acme_request_with_retries(retries: retries - 1, &block) end def wait_for_challenge_validation(challenge, cert_name) p 'Requesting validation of challenge' acme_request_with_retries { challenge.request_validation } while challenge.status == 'pending' p "Cert #{cert_name}: challenge validation is pending, sleeping before checking again" sleep(0.1) acme_request_with_retries { challenge.reload } end # pp challenge end def get_cert(order, cert_name, domains, domain_key) path = "./certs/#{cert_name}/" crt_file = "#{path}/cert.pem" p "Cert #{cert_name}: Creating CSR object" csr = Acme::Client::CertificateRequest.new( private_key: domain_key, names: domains, subject: { common_name: domains[0] } ) p "Cert #{cert_name}: Finalize cert order" # pp order # TODO: this seems unnecessary? # acme_request_with_retries { order.reload } acme_request_with_retries { order.finalize(csr: csr) } while order.status == 'processing' p "Cert #{cert_name}: Sleep while order is processing" sleep(0.1) p "Cert #{cert_name}: Rechecking order status" acme_request_with_retries { order.reload } end # p "order status: #{order.status}" # pp order cert = acme_request_with_retries { order.certificate } 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 def find_ca_for_cert(cert_name, cert_opts, config) cert_ca_account = cert_opts['ca_account'] || config.dig('defaults', 'certs', 'ca_account') # cert_ca_name = config.dig('ca_accounts', cert_ca_account, 'ca') # cert_ca_identity = config.dig('ca_accounts', cert_ca_account, 'identity') # # p "Cert #{cert_name}: Finding directory URL for CA" # acme_directory_url = config.dig('CAs', cert_ca_name, 'directory_url') # # p "Cert #{cert_name}: Finding account to use for cert #{cert_name} from CA #{cert_ca_name}" # account = config.dig('identities', cert_ca_account_name) # email = account['email'] { cert_name => cert_ca_account } # { # 'ca' => cert_ca_name, # 'account' => cert_ca_account_name, # }, # } end def make_client_for_ca_account(ca, id) p "CA #{ca['name']}: Finding directory URL for CA" acme_directory_url = ca['directory_url'] private_key = read_account_key(id['keyfile']) p "CA #{ca['name']}: Creating client object for communication with CA" client = Acme::Client.new(private_key: private_key, directory: acme_directory_url) email = id['email'] account_object = acme_request_with_retries { client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true) } client end def handle_apex_authzs(apex, authzs, config) p "apex: #{apex}" primary_ns = config.dig('domains', authzs[0].domain, 'primary_ns') || config.dig('defaults', 'domains', 'primary_ns') deploy_dns_tokens_on_apex(apex, authzs, primary_ns, config) end # domain_to_apex = {} # # domain_apex_threads = [] # domains_with_apex = domains.map do |domain| # apex_thread = Thread.new(domain) do |d| # find_apex_domain(d).to_s # end # domain_apex_threads << apex_thread # domain_to_apex[domain] = apex_thread.value # { apex_thread.value => [domain] } # end # domain_apex_threads.each(&:join) # # domains_per_apex = domains_with_apex[0].merge(*domains_with_apex[1..]) do |_key, old, new| # old + new # end # # p "domains_per_apex" # pp domains_per_apex config = read_config cert_dir = config.dig('global', 'cert_dir') || './certs/' ensure_cert_dir(cert_dir) # acme_threads = [] # # iterate over configured certs # config['certs'].each_pair do |cert_name, cert_opts| # acme_threads << Thread.new(cert_name, cert_opts) do |cert_name, cert_opts| # ensure_cert_dir(cert_dir + cert_name) # # 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_name = cert_ca['account'] # # p "Cert #{cert_name}: Finding directory URL for CA" # acme_directory_url = config.dig('CAs', cert_ca_name, '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_name) # email = account['email'] # # private_key = read_account_key(account['keyfile']) # # p "Cert #{cert_name}: Creating client object for communication with CA" # client = Acme::Client.new(private_key: private_key, directory: acme_directory_url) # # acme_request_with_retries { client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true) } # # p "Cert #{cert_name}: Creating order object for cert #{cert_name}" # order = acme_request_with_retries { client.new_order(identifiers: cert_opts['domain_names']) } # # 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" # auths = acme_request_with_retries { order.authorizations } # 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) # TODO: restructure process # DELAY THIS FOR NOW: check all certs’ lifetimes # DELAY THIS FOR NOW: decide which ones to renew # collect domain names for certs to be renewed # group domains in need of validation by apex_domain # collect ca_accounts that need to be used # THREAD PER ACCOUNT: create account object # hash: ca_account => account object # THREAD PER CA_ACCOUNT: create orders # THREAD PER DOMAIN: check validation status and collect domains needing validation # THREAD PER APEX DOMAIN: # THREAD PER DOMAIN: request challenge # deploy challenges with one DNS UPDATE per apex_domain # THREAD PER NS: check propagation # THREAD PER DOMAIN: # request validation # report back to main # THREAD PER CERT: when all domains for any cert are validated, finalize order domains = config['certs'].map { |_certname, cert_opts| cert_opts['domain_names'] }.flatten domain_attrs = domains.to_h { |d| [d, {}] } domain_apex_threads = {} domains.map do |d| domain_apex_threads[d] = Thread.new(d) do |d| p "finding apex for domain #{d}" find_apex_domain(d).to_s end end domain_apex_threads.each(&:join) domain_attrs.keys.each do |domain| domain_attrs[domain][:apex] = domain_apex_threads[domain].value end apex_domains = domain_attrs.map do |_, v| apex = v[:apex] domains_under_apex = domain_attrs.filter { |d,v| v[:apex] == apex }.keys [apex, { :domains => domains_under_apex }] end.uniq.to_h apex_domain_ns_threads = {} apex_domains.keys.map do |d| apex_domain_ns_threads[d] = Thread.new(d) do |d| p "finding ns for apex domain #{d}" res = lookup_ns(d) ns_names = res.answer.map do |answer| answer.rdata.to_s end end end apex_domain_ns_threads.each(&:join) apex_domains.keys.each do |domain| apex_domains[domain][:ns] = apex_domain_ns_threads[domain].value # p "#{domain}: #{apex_domains[domain][:ns]}" end # p apex_domains certs = config['certs'] certs.each_pair do |name, opts| opts['ca_account'] || certs[name]['ca_account'] = config.dig('defaults', 'certs', 'ca_account') end # # iterate over configured certs to find CA and account to use # cert_to_CA_account = config['certs'].map do |cert_name, cert_opts| # # ensure_cert_dir(cert_dir + cert_name) # # p "Cert #{cert_name}: Finding CA to use for cert" # find_ca_for_cert(cert_name, cert_opts, config) # end # # # p "cert_to_CA_account" # # pp cert_to_CA_account # # certs_to_CA = cert_to_CA_account[0].merge(*cert_to_CA_account[1..]) # # p "certs_to_CA" # # p certs_to_CA # unique_CA_accounts_to_use = certs_to_CA.values.uniq # p "unique_CA_accounts_to_use: #{unique_CA_accounts_to_use}" ca_accounts = certs.map { |_, opts| opts['ca_account'] }.uniq account_threads = [] ca_accounts.each do |ca_account| account_threads << Thread.new(ca_account) do |ca_account| ca_name = config.dig('ca_accounts', ca_account, 'ca') ca = config.dig('CAs', ca_name) ca_identity = config.dig('ca_accounts', ca_account, 'identity') identity = config.dig('identities', ca_identity) client = make_client_for_ca_account(ca, identity) { ca_account => client } end end account_threads.each(&:join) ca_clients = account_threads.map { |t| t.value } ca_clients = ca_clients[0].merge(*ca_clients[1..]) # ca_clients = {} # account_threads.each do |t| # ca_account_to_client = t.value # ca_clients[ca_account_to_client['account']] = ca_account_to_client['client'] # end # acme_threads = [] certs.each_pair do |cert_name, cert_opts| # p cert_opts # p cert_opts['ca_account'] client = ca_clients[cert_opts['ca_account']] # p "client" # p client domains = cert_opts['domain_names'] certs[cert_name]['order_thread'] = Thread.new(cert_name, client, domains) do |cert_name, client, domains| p "Cert #{cert_name}: Creating order object for cert #{cert_name}" acme_request_with_retries { client.new_order(identifiers: domains) } end end certs.each_pair do |cert_name, cert_opts| t = cert_opts['order_thread'] t.join certs[cert_name]['order'] = t.value end # pp certs # certs_to_CA.each_pair do |cert_name, ca| # # p "ca_clients[ca]: #{ca_clients[ca]}" # acme_threads << Thread.new(cert_name, ca_clients[ca], config) do |cert_name, client, config| # p "Cert #{cert_name}: Creating order object for cert #{cert_name}" # cert_opts = config['certs'][cert_name] # order = acme_request_with_retries { client.new_order(identifiers: cert_opts['domain_names']) } # { cert_name => order } # end # end # # acme_threads.each(&:join) # orders = acme_threads.map(&:value) # orders = orders[0].merge(*orders[1..]) # p "orders:" # pp orders # order_threads = [] # # for each order do # # if its ready skip to getting cert # # THREADS: get authorizations # authorizations = {} # orders.each_pair do |cert_name, order| # order_authorizations = acme_request_with_retries { order.authorizations } # authorizations[cert_name] = order_authorizations # end certs.each_pair do |cert, opts| order_authorizations = acme_request_with_retries { opts['order'].authorizations } order_authorizations.each do |auth| pp auth # pp auth.domain # pp auth.challenges # pp auth.url domain_attrs[auth.domain]['auth'] = auth end end all_authorizations = domain_attrs.map do |d, attrs| attrs['auth'] end authzs_by_apex = all_authorizations.group_by do |auth| domain_attrs[auth.domain][:apex] end authzs_by_apex.each_pair do |apex, authzs| handle_apex_authzs(apex, authzs, config) end propagation_threads = [] domain_attrs.each_pair do |d, attrs| domain_attrs[d][:propagation_thread] = Thread.new(attrs['auth']) do |auth| wait_for_challenge_propagation(auth.domain, auth.dns01) end propagation_threads << domain_attrs[d][:propagation_thread] end propagation_threads.each(&:join) validation_threads = {} domain_attrs.each_pair do |d, attrs| # domain_attrs[d][:auth_thread] = Thread.new(attrs) do |attrs| validation_threads[d] = Thread.new(attrs) do |attrs| # wait_for_challenge_validation(auth.domain, auth.dns01) # pp auth.dns01 # pp auth.dns01 wait_for_challenge_validation(attrs['auth'].dns01, attrs['auth'].domain) end # validation_threads[d] = domain_attrs[d][:auth_thread] end validation_threads.each_pair { |d, t| t.join } certs.each_pair do |name, opts| cert_key = read_cert_key(name) get_cert(opts['order'], name, opts['domain_names'], cert_key) end