]> git.netwichtig.de Git - user/henk/code/ruby/macir.git/blobdiff - macir.rb
change: format due to linter warning; sleeping time to be faster
[user/henk/code/ruby/macir.git] / macir.rb
index fb793233b8f7f16661b8aa0b88d9c7005d30927d..f371c36f1e1d7b390fb8df081edb2af9e09e7ce6 100644 (file)
--- a/macir.rb
+++ b/macir.rb
 #!/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
+  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}/"
-  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 )
+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
-    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"
-  tsig = Dnsruby::RR.create({
-    :name      => tsig_name,
-    :type      => 'TSIG',
-    :key       => tsig_key,
-    :algorithm => tsig_alg,
-  })
+  p "Domain #{domain}: 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"
+  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 "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 "Getting NS records for #{domain}"
-  domain_auth_ns = rec.query_no_validation_or_recursion( domain, "NS" )
+  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 )
-  p "Requesting validation of challenge"
-  challenge.request_validation
+def wait_for_challenge_validation(challenge, cert_name)
+  p 'Requesting validation of challenge'
+  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 "Checking again"
-    challenge.reload
+    p "Cert #{cert_name}: Sleeping because challenge validation is pending"
+    sleep(0.1)
+    p 'Checking again'
+    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]}/"
-  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"
+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"
+  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" ) then
-    File.unlink( File.dirname( cert_file ) + "/current.crt" )
-    File.symlink( File.basename( cert_file ), File.dirname( cert_file ) + "/current.crt" )
-  else
-    raise Exception
+  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
@@ -161,44 +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" then
-    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)
+
+    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
 
-    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 )
+    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
 
-    # deploy_dns01_challenge_token( cert_opts['domain_names'][0], challenge.record_content, cert_opts['challenge']['primary_ns'], config )
+    p "Cert #{cert_name}: order status"
+    p order.status
 
-  else
-    p "Order is ready, we don’t need to authorize"
-  end
-  domain_key = read_cert_key( cert_opts['domain_names'][0] )
+    if order.status != 'ready'
+      p "Cert #{cert_name}: Order is not ready, we need to authorize first"
 
-  get_cert( order, cert_opts['domain_names'], domain_key )
+      # 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)