]> git.netwichtig.de Git - user/henk/code/ruby/macir.git/commitdiff
initial public commit
authorHendrik Jäger <gitcommit@henk.geekmail.org>
Tue, 23 Jan 2024 19:37:42 +0000 (20:37 +0100)
committerHendrik Jäger <gitcommit@henk.geekmail.org>
Thu, 1 Feb 2024 11:03:05 +0000 (12:03 +0100)
macir.rb [new file with mode: 0644]

diff --git a/macir.rb b/macir.rb
new file mode 100644 (file)
index 0000000..9523a0f
--- /dev/null
+++ b/macir.rb
@@ -0,0 +1,208 @@
+#!/usr/bin/ruby
+
+# require 'net/http'
+# require 'json'
+require 'yaml'
+require 'openssl'
+require 'acme-client'
+require 'dnsruby'
+require 'time'
+
+
+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 => ex
+    $stderr.puts "IO failed: " + $!.to_s
+    p ex
+  end
+  return config
+end
+
+def read_account_key( path = 'pkey.pem' )
+  p "Reading account key from #{path}"
+  if File.readable?( path )
+    p "Key exists, trying to parse"
+    pkey_file = File.new( path )
+    privatekey_string = pkey_file.read
+    private_key = OpenSSL::PKey::EC.new( privatekey_string )
+  else
+    p "Key does not exist, trying to create"
+    pkey_file = File.new( path, 'w' )
+    private_key = OpenSSL::PKey::EC.generate( "prime256v1" )
+    pkey_pem = private_key.private_to_pem
+    pkey_file.write( pkey_pem )
+  end
+end
+
+def deploy_dns01_challenge_token( domain, token, nameserver, config )
+  p "Creating DNS UPDATE packet"
+  update = Dnsruby::Update.new( domain )
+  # TODO: delete challenge token record after validation
+  update.delete( "_acme-challenge." + domain , 'TXT' )
+  update.add( "_acme-challenge." + domain, 'TXT', 10, token )
+
+  p "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 "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"
+  tsig.apply(update)
+
+  p "Sending UPDATE to nameserver"
+  response = res.send_message(update)
+end
+
+def wait_for_challenge_propagation( domain, challenge )
+  p "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 "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|
+        p "Checking response"
+        p answer
+        p answer.rdata[0]
+        p "against challenge string"
+        p challenge.record_content
+        answer.rdata[0] == challenge.record_content
+      end
+      unless propagated
+        p "Sleeping before checking again"
+        sleep(1)
+      end
+    end until propagated
+  end
+end
+
+def wait_for_challenge_validation( challenge )
+  p "Requesting validation of challenge"
+  challenge.request_validation
+
+  while challenge.status == 'pending'
+    p "Sleeping because challenge validation is pending"
+    sleep(1)
+    p "Checking again"
+    challenge.reload
+  end
+end
+
+def get_cert_key( domain )
+  path = "./domains/#{domain}/"
+  key_file = path + "current.key"
+  p "Reading cert key from #{key_file}"
+  if File.readable?( key_file )
+    p "Cert key is readable, trying to read"
+    pkey_file = File.new( key_file )
+    privatekey_string = pkey_file.read
+    domain_key = OpenSSL::PKey::EC.new( privatekey_string )
+  else
+    p "Cert key is not readable, trying to create one"
+    pkey_file = File.new( path + Time.now.to_i.to_s + ".key", 'w' )
+    domain_key = OpenSSL::PKey::EC.generate( "prime256v1" )
+    pkey_pem = domain_key.private_to_pem
+    pkey_file.write( pkey_pem )
+    File.symlink( File.basename( pkey_file ), File.dirname( pkey_file ) + "/current.key" )
+  end
+  return domain_key
+end
+
+def get_cert( order, domains, domain_key )
+  path = "./domains/#{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"
+    order.reload
+  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
+  end
+  return cert
+end
+
+
+config = read_config
+
+# 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'] )
+  if order.status != "ready" then
+    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}"
+      p config['domains'][auth.domain]['challenge']
+      challenge = auth.dns01
+      deploy_dns01_challenge_token( auth.domain, challenge.record_content, config['domains'][auth.domain]['primary_ns'], config )
+      wait_for_challenge_propagation( auth.domain, challenge )
+      wait_for_challenge_validation( challenge )
+    end
+
+    # deploy_dns01_challenge_token( cert_opts['domain_names'][0], challenge.record_content, cert_opts['challenge']['primary_ns'], config )
+
+  else
+    p "Order is ready, we don’t need to authorize"
+  end
+  domain_key = get_cert_key( cert_opts['domain_names'][0] )
+
+  get_cert( order, cert_opts['domain_names'], domain_key )
+end