]> git.netwichtig.de Git - user/henk/code/ruby/macir.git/commitdiff
cleanup linter grievances; general tidying; less sleeping
authorHendrik Jäger <gitcommit@henk.geekmail.org>
Fri, 2 Feb 2024 21:49:42 +0000 (22:49 +0100)
committerHendrik Jäger <gitcommit@henk.geekmail.org>
Fri, 2 Feb 2024 21:49:42 +0000 (22:49 +0100)
macir.rb

index 3c8ca0c1da6069e93e985c7a850149cc2f6f07ad..e950fb7a0b1f83c235e8d09cd49763e65c771c4d 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
-    raise
-  end
-  return config
+  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' )
-  if not File.exist?( path )
+def ensure_cert_dir(path = './certs')
+  unless 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.' )
+    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' )
+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( cert_name )
+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 )
+  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 )
+    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( "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.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 )
+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"
-  update = Dnsruby::Update.new( domain )
+
+  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 "Domain #{domain}: Creating object for contacting nameserver"
-  res = Dnsruby::Resolver.new( nameserver )
+  res = Dnsruby::Resolver.new(nameserver)
   res.dnssec = false
 
   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
+  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 "Domain #{domain}: Creating TSIG object"
   tsig = Dnsruby::RR.create(
@@ -112,22 +117,38 @@ def deploy_dns01_challenge_token( domain, challenge, nameserver, config )
 
   p "Domain #{domain}: Signing DNS UPDATE packet with TSIG object"
   tsig.apply(update)
-  p 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)
+  rec = Dnsruby::Recursor.new
+  p "Domain #{domain}: Getting SOA records for #{domain}"
   begin
-    response = res.send_message(update)
-  rescue Exception
-    $stderr.puts "Domain #{domain}: IO failed: " + $!.to_s
+    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
-end
+  apex_domain = if domain_soa_resp.answer.empty?
+                  domain_soa_resp.authority[0].name
+                else
+                  domain_soa_resp.answer[0].name
+                end
 
-def wait_for_challenge_propagation( domain, challenge )
   p "Domain #{domain}: Creating recursor object for checking challenge propagation"
   rec = Dnsruby::Recursor.new
-  p "Domain #{domain}: 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"
 
@@ -137,65 +158,121 @@ def wait_for_challenge_propagation( domain, challenge )
     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 = 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" )
+        begin
+          result = res.query_no_validation_or_recursion("_acme-challenge.#{domain}", 'TXT')
+        rescue Dnsruby::NXDomain
+          p "Domain #{domain}: Not yet propagated, sleeping before checking again"
+          Thread.pass
+          sleep(0.1)
+          retry
+        rescue StandardError => e
+          warn "Domain #{domain}: ACME challenge lookup failed: #{e}"
+          raise
+        end
         # 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)
+          Thread.pass
+          sleep(0.1)
         end
       end until propagated
     end
   end
 
-  threads.each { |thread| thread.join }
+  threads.each(&:join)
 end
 
-def wait_for_challenge_validation( challenge )
+def wait_for_challenge_validation(challenge)
   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 '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, cert_name, domains, domain_key )
+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]}" }
+    subject: { common_name: domains[0] }
   )
   p "Cert #{cert_name}: Finalize cert order"
-  order.finalize(csr: csr)
+  begin
+    retries ||= 0
+    order.reload
+  rescue Acme::Client::Error::BadNonce
+    retries += 1
+    p 'Retrying because of invalid nonce.'
+    retry if retries <= 5
+  end
+  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(1)
     p "Cert #{cert_name}: Rechecking order status"
-    order.reload
+    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
-  cert = order.certificate
 
   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" )
-  elsif File.file?( File.dirname( cert_file ) + "/current.crt" )
+  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")
+  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
@@ -206,63 +283,89 @@ config = read_config
 
 cert_dir = config.dig('global', 'cert_dir') || './certs/'
 
-ensure_cert_dir( cert_dir )
+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|
   acme_threads << Thread.new(cert_name, cert_opts) do |cert_name, cert_opts|
-    ensure_cert_dir( cert_dir + cert_name )
+    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 = 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' )
+    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 )
+    account = config.dig('ca_accounts', cert_ca_account)
     email = account['email']
 
-    private_key = read_account_key( account['keyfile'] )
+    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 )
-
-    client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true)
+    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 "Cert #{cert_name}: Creating order object for cert #{cert_name}"
-    order = client.new_order(identifiers: cert_opts['domain_names'] )
+    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"
 
       p "Cert #{cert_name}: Iterating over required authorizations"
-      order.authorizations.each do |auth|
+      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 )
+        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
-    domain_key = read_cert_key( cert_name )
+    domain_key = read_cert_key(cert_name)
 
-    get_cert( order, cert_name, 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 }
+acme_threads.each(&:join)