]> git.netwichtig.de Git - user/henk/code/ruby/macir.git/commitdiff
dirty
authorHendrik Jäger <gitcommit@henk.geekmail.org>
Fri, 9 Feb 2024 12:49:07 +0000 (13:49 +0100)
committerHendrik Jäger <gitcommit@henk.geekmail.org>
Fri, 9 Feb 2024 12:49:07 +0000 (13:49 +0100)
macir.rb

index f923839db727d8174d42ecd81d6a3c998f3fa830..cdb9b611f04762a5a988ea2140783f6768c2c292 100644 (file)
--- a/macir.rb
+++ b/macir.rb
@@ -68,8 +68,18 @@ def read_cert_key(cert_name)
   return private_key
 end
 
+def lookup_ns(domain)
+  p "Domain #{domain}: Creating recursor object for checking challenge propagation"
+  rec = Dnsruby::Resolver.new
+  p "Domain #{domain}: Getting NS records for #{domain}"
+  rec.query_no_validation_or_recursion(domain, 'NS')
+rescue StandardError => e
+  warn "Domain #{domain}: NS lookup during deploy failed: #{e}"
+  raise
+end
+
 def lookup_soa(domain)
-  rec = Dnsruby::Recursor.new
+  rec = Dnsruby::Resolver.new
   p "Domain #{domain}: Getting SOA records for #{domain}"
   rec.query_no_validation_or_recursion(domain, 'SOA')
 rescue StandardError => e
@@ -86,6 +96,53 @@ def find_apex_domain(domain)
   end
 end
 
+def build_dns_update_packet(apex, authzs)
+  update = Dnsruby::Update.new(apex)
+
+  authzs.each do |auth|
+    chal = auth.dns01
+    update.delete("#{chal.record_name}.#{auth.domain}", chal.record_type)
+    update.add("#{chal.record_name}.#{auth.domain}", chal.record_type, 3, chal.record_content)
+  end
+  return update
+end
+
+def build_tsig_object(apex, config)
+  p "Domain #{apex}: Looking up TSIG parameters"
+  tsig_name = config.dig('domains', apex, '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 #{apex}: Creating TSIG object"
+  tsig = Dnsruby::RR.create(
+    {
+      name: tsig_name,
+      type: 'TSIG',
+      key: tsig_key,
+      algorithm: tsig_alg,
+    }
+  )
+end
+
+def deploy_dns_tokens_on_apex(apex, authzs, nameserver, config)
+  update_packet = build_dns_update_packet(apex, authzs)
+
+  p "Domain #{apex}: Creating object for contacting nameserver"
+  res = Dnsruby::Resolver.new(nameserver)
+  res.dnssec = false
+
+  tsig = build_tsig_object(apex, config)
+
+  p "Domain #{apex}: Signing DNS UPDATE packet with TSIG object"
+  tsig.apply(update_packet)
+
+  p "Domain #{apex}: Sending UPDATE to nameserver"
+  res.send_message(update_packet)
+rescue StandardError => e
+  warn "Domain #{apex}: DNS Update failed: #{e}"
+  raise
+end
+
 def deploy_dns01_challenge_token(domain, challenge, nameserver, config)
   p "Domain #{domain}: Creating DNS UPDATE packet"
 
@@ -100,20 +157,7 @@ def deploy_dns01_challenge_token(domain, challenge, nameserver, config)
   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 "Domain #{domain}: Creating TSIG object"
-  tsig = Dnsruby::RR.create(
-    {
-      name: tsig_name,
-      type: 'TSIG',
-      key: tsig_key,
-      algorithm: tsig_alg,
-    }
-  )
+  tsig = build_tsig_object(domain, config)
 
   p "Domain #{domain}: Signing DNS UPDATE packet with TSIG object"
   tsig.apply(update)
@@ -126,29 +170,8 @@ rescue StandardError => e
 end
 
 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 "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
+  apex_domain = find_apex_domain(domain)
+  domain_auth_ns = lookup_ns(apex_domain)
 
   p "Domain #{domain}: Checking challenge status on all NS"
 
@@ -186,7 +209,7 @@ def wait_for_challenge_propagation(domain, challenge)
 end
 
 def acme_request_with_retries(retries: 5, &block)
-  p "Retries: #{retries}"
+  p "Retries: #{retries}"
   block.call(self)
 rescue Acme::Client::Error::BadNonce
   raise unless retries.positive?
@@ -200,11 +223,11 @@ def wait_for_challenge_validation(challenge, cert_name)
   acme_request_with_retries { challenge.request_validation }
 
   while challenge.status == 'pending'
-    p "Cert #{cert_name}: Sleeping because challenge validation is pending"
+    p "Cert #{cert_name}: challenge validation is pending, sleeping before checking again"
     sleep(0.1)
-    p 'Checking again'
     acme_request_with_retries { challenge.reload }
   end
+  # pp challenge
 end
 
 def get_cert(order, cert_name, domains, domain_key)
@@ -217,7 +240,9 @@ def get_cert(order, cert_name, domains, domain_key)
     subject: { common_name: domains[0] }
   )
   p "Cert #{cert_name}: Finalize cert order"
-  acme_request_with_retries { order.reload }
+  # pp order
+  # TODO: this seems unnecessary?
+  # acme_request_with_retries { order.reload }
   acme_request_with_retries { order.finalize(csr: csr) }
   while order.status == 'processing'
     p "Cert #{cert_name}: Sleep while order is processing"
@@ -241,6 +266,67 @@ def get_cert(order, cert_name, domains, domain_key)
   return cert
 end
 
+def find_ca_for_cert(cert_name, cert_opts, config)
+  cert_ca_account = cert_opts['ca_account'] || config.dig('defaults', 'certs', 'ca_account')
+  # cert_ca_name = config.dig('ca_accounts', cert_ca_account, 'ca')
+  # cert_ca_identity = config.dig('ca_accounts', cert_ca_account, 'identity')
+  #
+  # 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('identities', cert_ca_account_name)
+  # email = account['email']
+  { cert_name => cert_ca_account }
+  #   {
+  #     'ca' => cert_ca_name,
+  #     'account' => cert_ca_account_name,
+  #   },
+  # }
+end
+
+def make_client_for_ca_account(ca, id)
+  p "CA #{ca['name']}: Finding directory URL for CA"
+  acme_directory_url = ca['directory_url']
+
+  private_key = read_account_key(id['keyfile'])
+
+  p "CA #{ca['name']}: Creating client object for communication with CA"
+  client = Acme::Client.new(private_key: private_key, directory: acme_directory_url)
+
+  email = id['email']
+
+  account_object = acme_request_with_retries { client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true) }
+  client
+end
+
+def handle_apex_authzs(apex, authzs, config)
+  p "apex: #{apex}"
+  primary_ns = config.dig('domains', authzs[0].domain, 'primary_ns') || config.dig('defaults', 'domains', 'primary_ns')
+  deploy_dns_tokens_on_apex(apex, authzs, primary_ns, config)
+end
+
+
+# domain_to_apex = {}
+#
+# domain_apex_threads = []
+# domains_with_apex = domains.map do |domain|
+#   apex_thread = Thread.new(domain) do |d|
+#     find_apex_domain(d).to_s
+#   end
+#   domain_apex_threads << apex_thread
+#   domain_to_apex[domain] = apex_thread.value
+#   { apex_thread.value => [domain] }
+# end
+# domain_apex_threads.each(&:join)
+#
+# domains_per_apex = domains_with_apex[0].merge(*domains_with_apex[1..]) do |_key, old, new|
+#   old + new
+# end
+#
+# p "domains_per_apex"
+# pp domains_per_apex
+
 
 config = read_config
 
@@ -248,68 +334,281 @@ 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|
-  acme_threads << Thread.new(cert_name, cert_opts) do |cert_name, cert_opts|
-    ensure_cert_dir(cert_dir + cert_name)
+# acme_threads = []
+# # iterate over configured certs
+# 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)
+#
+#     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_name = 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_name)
+#     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)
+#
+#     acme_request_with_retries { client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true) }
+#
+#     p "Cert #{cert_name}: Creating order object for cert #{cert_name}"
+#     order = acme_request_with_retries { 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"
+#
+#       # TODO: collect dns modifications per primary NS, update all at once
+#       p "Cert #{cert_name}: Iterating over required authorizations"
+#       auths = acme_request_with_retries { order.authorizations }
+#       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)
+
+
+# TODO: restructure process
+# DELAY THIS FOR NOW: check all certs’ lifetimes
+# DELAY THIS FOR NOW: decide which ones to renew
+# collect domain names for certs to be renewed
+# group domains in need of validation by apex_domain
+# collect ca_accounts that need to be used
+# THREAD PER ACCOUNT: create account object
+# hash: ca_account => account object
+# THREAD PER CA_ACCOUNT: create orders
+# THREAD PER DOMAIN: check validation status and collect domains needing validation
+# THREAD PER APEX DOMAIN:
+  # THREAD PER DOMAIN: request challenge
+  # deploy challenges with one DNS UPDATE per apex_domain
+  # THREAD PER NS: check propagation
+  # THREAD PER DOMAIN:
+    # request validation
+    # report back to main
+# THREAD PER CERT: when all domains for any cert are validated, finalize order
+
+
+domains = config['certs'].map { |_certname, cert_opts| cert_opts['domain_names'] }.flatten
+domain_attrs = domains.to_h { |d| [d, {}] }
+
+domain_apex_threads = {}
+domains.map do |d|
+  domain_apex_threads[d] = Thread.new(d) do |d|
+    p "finding apex for domain #{d}"
+    find_apex_domain(d).to_s
+  end
+end
+domain_apex_threads.each(&:join)
+
+domain_attrs.keys.each do |domain|
+  domain_attrs[domain][:apex] = domain_apex_threads[domain].value
+end
 
-    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']
+apex_domains = domain_attrs.map do |_, v|
+  apex = v[:apex]
+  domains_under_apex = domain_attrs.filter { |d,v| v[:apex] == apex }.keys
+  [apex, { :domains => domains_under_apex }]
+end.uniq.to_h
 
-    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']
+apex_domain_ns_threads = {}
+apex_domains.keys.map do |d|
+  apex_domain_ns_threads[d] = Thread.new(d) do |d|
+    p "finding ns for apex domain #{d}"
+    res = lookup_ns(d)
+    ns_names = res.answer.map do |answer|
+      answer.rdata.to_s
+    end
+  end
+end
+apex_domain_ns_threads.each(&:join)
 
-    private_key = read_account_key(account['keyfile'])
+apex_domains.keys.each do |domain|
+  apex_domains[domain][:ns] = apex_domain_ns_threads[domain].value
+  # p "#{domain}: #{apex_domains[domain][:ns]}"
+end
+# p apex_domains
 
-    p "Cert #{cert_name}: Creating client object for communication with CA"
-    client = Acme::Client.new(private_key: private_key, directory: acme_directory_url)
 
-    acme_request_with_retries { client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true) }
+certs = config['certs']
+certs.each_pair do |name, opts|
+  opts['ca_account'] || certs[name]['ca_account'] = config.dig('defaults', 'certs', 'ca_account')
+end
 
+# # iterate over configured certs to find CA and account to use
+# cert_to_CA_account = config['certs'].map do |cert_name, cert_opts|
+#   # ensure_cert_dir(cert_dir + cert_name)
+#
+#   p "Cert #{cert_name}: Finding CA to use for cert"
+#   find_ca_for_cert(cert_name, cert_opts, config)
+# end
+#
+# # p "cert_to_CA_account"
+# # pp cert_to_CA_account
+#
+# certs_to_CA = cert_to_CA_account[0].merge(*cert_to_CA_account[1..])
+# # p "certs_to_CA"
+# # p certs_to_CA
+
+
+# unique_CA_accounts_to_use = certs_to_CA.values.uniq
+# p "unique_CA_accounts_to_use: #{unique_CA_accounts_to_use}"
+
+ca_accounts = certs.map { |_, opts| opts['ca_account'] }.uniq
+
+
+account_threads = []
+ca_accounts.each do |ca_account|
+  account_threads << Thread.new(ca_account) do |ca_account|
+    ca_name = config.dig('ca_accounts', ca_account, 'ca')
+    ca = config.dig('CAs', ca_name)
+    ca_identity = config.dig('ca_accounts', ca_account, 'identity')
+    identity = config.dig('identities', ca_identity)
+    client = make_client_for_ca_account(ca, identity)
+    {
+      ca_account => client
+    }
+  end
+end
+account_threads.each(&:join)
+
+ca_clients = account_threads.map { |t| t.value }
+ca_clients = ca_clients[0].merge(*ca_clients[1..])
+
+# ca_clients = {}
+# account_threads.each do |t|
+#   ca_account_to_client = t.value
+#   ca_clients[ca_account_to_client['account']] = ca_account_to_client['client']
+# end
+
+
+# acme_threads = []
+certs.each_pair do |cert_name, cert_opts|
+  # p cert_opts
+  # p cert_opts['ca_account']
+  client = ca_clients[cert_opts['ca_account']]
+  # p "client"
+  # p client
+  domains = cert_opts['domain_names']
+  certs[cert_name]['order_thread'] = Thread.new(cert_name, client, domains) do |cert_name, client, domains|
     p "Cert #{cert_name}: Creating order object for cert #{cert_name}"
-    order = acme_request_with_retries { 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"
-
-      # TODO: collect dns modifications per primary NS, update all at once
-      p "Cert #{cert_name}: Iterating over required authorizations"
-      auths = acme_request_with_retries { order.authorizations }
-      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
+    acme_request_with_retries { client.new_order(identifiers: domains) }
+  end
+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)
+certs.each_pair do |cert_name, cert_opts|
+  t = cert_opts['order_thread']
+  t.join
+  certs[cert_name]['order'] = t.value
+end
+
+# pp certs
+
+# certs_to_CA.each_pair do |cert_name, ca|
+#   # p "ca_clients[ca]: #{ca_clients[ca]}"
+#   acme_threads << Thread.new(cert_name, ca_clients[ca], config) do |cert_name, client, config|
+#     p "Cert #{cert_name}: Creating order object for cert #{cert_name}"
+#     cert_opts = config['certs'][cert_name]
+#     order = acme_request_with_retries { client.new_order(identifiers: cert_opts['domain_names']) }
+#     { cert_name => order }
+#   end
+# end
+#
+# acme_threads.each(&:join)
+# orders = acme_threads.map(&:value)
+# orders = orders[0].merge(*orders[1..])
+# p "orders:"
+# pp orders
+
+# order_threads = []
+# # for each order do
+# #   if its ready skip to getting cert
+# #   THREADS: get authorizations
+# authorizations = {}
+# orders.each_pair do |cert_name, order|
+#   order_authorizations = acme_request_with_retries { order.authorizations }
+#   authorizations[cert_name] = order_authorizations
+# end
+
+certs.each_pair do |cert, opts|
+  order_authorizations = acme_request_with_retries { opts['order'].authorizations }
+  order_authorizations.each do |auth|
+    pp auth
+    # pp auth.domain
+    # pp auth.challenges
+    # pp auth.url
+    domain_attrs[auth.domain]['auth'] = auth
+  end
+end
 
-    get_cert(order, cert_name, cert_opts['domain_names'], domain_key)
+all_authorizations = domain_attrs.map do |d, attrs|
+  attrs['auth']
+end
+
+authzs_by_apex = all_authorizations.group_by do |auth|
+  domain_attrs[auth.domain][:apex]
+end
+
+authzs_by_apex.each_pair do |apex, authzs|
+  handle_apex_authzs(apex, authzs, config)
+end
+
+propagation_threads = []
+domain_attrs.each_pair do |d, attrs|
+  domain_attrs[d][:propagation_thread] = Thread.new(attrs['auth']) do |auth|
+    wait_for_challenge_propagation(auth.domain, auth.dns01)
   end
+  propagation_threads << domain_attrs[d][:propagation_thread]
 end
 
-acme_threads.each(&:join)
+propagation_threads.each(&:join)
+
+validation_threads = {}
+domain_attrs.each_pair do |d, attrs|
+  # domain_attrs[d][:auth_thread] = Thread.new(attrs) do |attrs|
+  validation_threads[d] = Thread.new(attrs) do |attrs|
+    # wait_for_challenge_validation(auth.domain, auth.dns01)
+    # pp auth.dns01
+    # pp auth.dns01
+    wait_for_challenge_validation(attrs['auth'].dns01, attrs['auth'].domain)
+  end
+  # validation_threads[d] = domain_attrs[d][:auth_thread]
+end
+
+validation_threads.each_pair { |d, t| t.join }
+
+certs.each_pair do |name, opts|
+  cert_key = read_cert_key(name)
+  get_cert(opts['order'], name, opts['domain_names'], cert_key)
+end