X-Git-Url: https://git.netwichtig.de/gitweb/?a=blobdiff_plain;f=macir.rb;h=f371c36f1e1d7b390fb8df081edb2af9e09e7ce6;hb=db7ded836d43cf0860b76dfec6f0efa5305b1ede;hp=57ec69e783be82629fa3c0076e011d1999c6117d;hpb=14b4edf199e865cc50f03544be3c11925d50460d;p=user%2Fhenk%2Fcode%2Fruby%2Fmacir.git diff --git a/macir.rb b/macir.rb index 57ec69e..f371c36 100644 --- a/macir.rb +++ b/macir.rb @@ -1,83 +1,111 @@ #!/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, @@ -87,80 +115,160 @@ 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 '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 @@ -168,41 +276,93 @@ 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)