summaryrefslogtreecommitdiff
path: root/macir.rb
diff options
context:
space:
mode:
authorHendrik Jäger <gitcommit@henk.geekmail.org>2024-02-02 00:03:27 +0100
committerHendrik Jäger <gitcommit@henk.geekmail.org>2024-02-02 00:03:27 +0100
commit569f79b0b52e224c22ea725464799a886b40aef0 (patch)
treee9ec221f99bb799a0259986b6c2e9479c316cdbe /macir.rb
parentaf51d1605fdb933518e5e0e61082fe93548c0916 (diff)
add threads; better config lookups; tidying
Diffstat (limited to 'macir.rb')
-rw-r--r--macir.rb219
1 files changed, 136 insertions, 83 deletions
diff --git a/macir.rb b/macir.rb
index 64e9d81..3c8ca0c 100644
--- 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 }