From 59e6bf30ba692061c6ee2617deb30b8556ffd07f Mon Sep 17 00:00:00 2001 From: Hendrik Jäger Date: Tue, 23 Jan 2024 20:37:42 +0100 Subject: initial public commit --- macir.rb | 208 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 macir.rb diff --git a/macir.rb b/macir.rb new file mode 100644 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 -- cgit v1.2.3