]> git.netwichtig.de Git - user/henk/code/ruby/macir.git/commitdiff
add threads; better config lookups; tidying
authorHendrik Jäger <gitcommit@henk.geekmail.org>
Thu, 1 Feb 2024 23:03:27 +0000 (00:03 +0100)
committerHendrik Jäger <gitcommit@henk.geekmail.org>
Thu, 1 Feb 2024 23:03:27 +0000 (00:03 +0100)
macir.rb

index 64e9d8121dd2182908d27129c8341e94b812b4bd..3c8ca0c1da6069e93e985c7a850149cc2f6f07ad 100644 (file)
--- 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 }