From 569f79b0b52e224c22ea725464799a886b40aef0 Mon Sep 17 00:00:00 2001 From: Hendrik Jäger Date: Fri, 2 Feb 2024 00:03:27 +0100 Subject: add threads; better config lookups; tidying --- macir.rb | 219 +++++++++++++++++++++++++++++++++++++++------------------------ 1 file changed, 136 insertions(+), 83 deletions(-) diff --git a/macir.rb b/macir.rb index 64e9d81..3c8ca0c 100644 --- a/macir.rb +++ b/macir.rb @@ -21,6 +21,26 @@ def read_config( path = 'config.yaml' ) return config end +def ensure_cert_dir( path = './certs' ) + if not File.exist?( path ) + puts 'Certificate directory does not exist. Creating with secure permissions.' + Dir.mkdir( path, 0700 ) + File.chmod( 0700, path ) + end + if File.world_writable?( path ) + $stderr.puts "WARNING! Certificate directory is world writable! This could be a serious security issue!" + end + if File.world_readable?( path ) + $stderr.puts "WARNING! Certificate directory is world readable! This could be a serious security issue!" + end + if File.file?( path ) + raise( 'Certificate directory is not a directory but a file. Aborting.' ) + end + if not File.writable?( path ) + raise( 'Certificate directory is not writable. Aborting.' ) + end +end + def read_account_key( path = 'pkey.pem' ) p "Reading account key from #{path}" if File.readable?( path ) @@ -39,19 +59,19 @@ def read_account_key( path = 'pkey.pem' ) 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}" + p "cert_name #{cert_name}: Reading cert key from #{path}" if File.readable?( path ) - p "File #{path} is readable, trying to parse" + 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 ) else if File.exists?( path ) - raise( "The file #{path} exists but is not readable. Make it readable or specify different path" ) + raise( "cert_name #{cert_name}: 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" + 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.to_s + ".key", 'w' ) pkey_file.write( private_key.private_to_pem ) @@ -62,22 +82,25 @@ def read_cert_key( domain ) end def deploy_dns01_challenge_token( domain, challenge, nameserver, config ) - p 'Creating DNS UPDATE packet' + p "Domain #{domain}: 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' + 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 tsig_name + p tsig_key + p tsig_alg - p 'Creating TSIG object' + p "Domain #{domain}: Creating TSIG object" tsig = Dnsruby::RR.create( { name: tsig_name, @@ -87,39 +110,52 @@ def deploy_dns01_challenge_token( domain, challenge, nameserver, config ) } ) - p 'Signing DNS UPDATE packet with TSIG object' + p "Domain #{domain}: Signing DNS UPDATE packet with TSIG object" tsig.apply(update) + p update - p 'Sending UPDATE to nameserver' - response = res.send_message(update) + p "Domain #{domain}: Sending UPDATE to nameserver" + begin + response = res.send_message(update) + rescue Exception + $stderr.puts "Domain #{domain}: IO failed: " + $!.to_s + raise + end end def wait_for_challenge_propagation( domain, challenge ) - p 'Creating recursor object for checking challenge propagation' + p "Domain #{domain}: Creating recursor object for checking challenge propagation" rec = Dnsruby::Recursor.new - p "Getting NS records for #{domain}" + p "Domain #{domain}: Getting NS records for #{domain}" domain_auth_ns = rec.query_no_validation_or_recursion( domain, "NS" ) - p 'Checking challenge status on all NS' + p "Domain #{domain}: Checking challenge status on all NS" + + threads = [] + 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 + 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 + begin + p "Domain #{domain}: 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 "Domain #{domain}: Not yet propagated, sleeping before checking again" + sleep(1) + end + end until propagated + end end + + threads.each { |thread| thread.join } end def wait_for_challenge_validation( challenge ) @@ -134,33 +170,33 @@ def wait_for_challenge_validation( challenge ) 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]}" } ) - p 'Finalize cert order' + p "Cert #{cert_name}: Finalize cert order" order.finalize(csr: csr) while order.status == 'processing' - p 'Sleep while order is processing' + p "Cert #{cert_name}: Sleep while order is processing" sleep(1) - p 'Rechecking order status' + p "Cert #{cert_name}: Rechecking order status" order.reload end cert = order.certificate - p 'Writing cert' + p "Cert #{cert_name}: 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 + 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 @@ -168,48 +204,65 @@ 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 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'] ) - p 'order status' - p order.status - 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}" - if auth.status == 'valid' - p "Authorization for #{auth.domain} is still valid, skipping" - next - end + 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 = 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 ) + 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 ) - 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 ) + client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true) + + p "Cert #{cert_name}: Creating order object for cert #{cert_name}" + order = 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" + + p "Cert #{cert_name}: Iterating over required authorizations" + order.authorizations.each do |auth| + p "Cert #{cert_name}: Processing authorization for #{auth.domain}" + p "Cert #{cert_name}: Finding challenge type for #{auth.domain}" + 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 ) + end + else + p "Cert #{cert_name}: Order is ready, we don’t need to authorize" end - else - p 'Order is ready, we don’t need to authorize' - end - domain_key = read_cert_key( cert_opts['domain_names'][0] ) + domain_key = read_cert_key( cert_name ) - get_cert( order, cert_opts['domain_names'], domain_key ) + get_cert( order, cert_name, cert_opts['domain_names'], domain_key ) + end end + +acme_threads.each { |thread| thread.join } -- cgit v1.2.3