3 # frozen_string_literal: true
13 def read_config(path = 'config.yaml')
14 p "Reading config from #{path}"
16 rescue Psych::SyntaxError => e
17 warn "Parsing configfile failed: #{e}"
19 rescue Errno::ENOENT => e
20 warn "IO failed: #{e}"
24 def ensure_cert_dir(path = './certs')
25 unless File.exist?(path)
26 puts 'Certificate directory does not exist. Creating with secure permissions.'
27 Dir.mkdir(path, 0o0700)
29 File.world_writable?(path) && warn('WARNING! Certificate directory is world writable! This could be a serious security issue!')
30 File.world_readable?(path) && warn('WARNING! Certificate directory is world readable! This could be a serious security issue!')
31 File.file?(path) && raise('Certificate directory is not a directory but a file. Aborting.')
32 File.writable?(path) || raise('Certificate directory is not writable. Aborting.')
35 def read_account_key(path = 'pkey.pem')
36 p "Reading account key from #{path}"
37 if File.readable?(path)
38 p "File #{path} is readable, trying to parse"
39 privatekey_string = File.read(path)
40 private_key = OpenSSL::PKey::EC.new(privatekey_string)
41 elsif File.exist?(path)
42 raise("The file #{path} exists but is not readable. Make it readable or specify different path")
44 p "File #{path} does not exist, trying to create"
45 private_key = OpenSSL::PKey::EC.generate('prime256v1')
46 File.write(path, private_key.private_to_pem)
51 def read_cert_key(cert_name)
52 folder = "./certs/#{cert_name}/"
53 path = "#{folder}/current.key"
54 p "cert_name #{cert_name}: Reading cert key from #{path}"
55 if File.readable?(path)
56 p "cert_name #{cert_name}: File #{path} is readable, trying to parse"
57 privatekey_string = File.read(path)
58 private_key = OpenSSL::PKey::EC.new(privatekey_string)
59 elsif File.exist?(path)
60 raise("cert_name #{cert_name}: The file #{path} exists but is not readable. Make it readable or specify different path")
62 p "cert_name #{cert_name}: File #{path} does not exist, trying to create"
63 private_key = OpenSSL::PKey::EC.generate('prime256v1')
64 pkey_file = File.new("#{folder}#{Time.now.to_i}.key", 'w')
65 pkey_file.write(private_key.private_to_pem)
66 File.symlink(File.basename(pkey_file), "#{File.dirname(pkey_file)}/current.key")
71 def lookup_soa(domain)
72 rec = Dnsruby::Recursor.new
73 p "Domain #{domain}: Getting SOA records for #{domain}"
74 rec.query_no_validation_or_recursion(domain, 'SOA')
75 rescue StandardError => e
76 warn "Domain #{domain}: SOA lookup during deploy failed: #{e}"
80 def find_apex_domain(domain)
81 domain_soa_resp = lookup_soa(domain)
82 if domain_soa_resp.answer.empty?
83 domain_soa_resp.authority[0].name
85 domain_soa_resp.answer[0].name
89 def deploy_dns01_challenge_token(domain, challenge, nameserver, config)
90 p "Domain #{domain}: Creating DNS UPDATE packet"
92 apex_domain = find_apex_domain(domain)
94 update = Dnsruby::Update.new(apex_domain)
95 # TODO: delete challenge token record after validation
96 update.delete("#{challenge.record_name}.#{domain}", challenge.record_type)
97 update.add("#{challenge.record_name}.#{domain}", challenge.record_type, 10, challenge.record_content)
99 p "Domain #{domain}: Creating object for contacting nameserver"
100 res = Dnsruby::Resolver.new(nameserver)
103 p "Domain #{domain}: Looking up TSIG parameters"
104 tsig_name = config.dig('domains', domain, 'tsig_key') || config.dig('defaults', 'domains', 'tsig_key')
105 tsig_key = config.dig('tsig_keys', tsig_name, 'key')
106 tsig_alg = config.dig('tsig_keys', tsig_name, 'algorithm')
108 p "Domain #{domain}: Creating TSIG object"
109 tsig = Dnsruby::RR.create(
118 p "Domain #{domain}: Signing DNS UPDATE packet with TSIG object"
121 p "Domain #{domain}: Sending UPDATE to nameserver"
122 res.send_message(update)
123 rescue StandardError => e
124 warn "Domain #{domain}: DNS Update failed: #{e}"
128 def wait_for_challenge_propagation(domain, challenge)
129 rec = Dnsruby::Recursor.new
130 p "Domain #{domain}: Getting SOA records for #{domain}"
132 domain_soa_resp = rec.query_no_validation_or_recursion(domain, 'SOA')
133 rescue StandardError => e
134 warn "Domain #{domain}: SOA lookup during propagation wait failed: #{e}"
137 apex_domain = if domain_soa_resp.answer.empty?
138 domain_soa_resp.authority[0].name
140 domain_soa_resp.answer[0].name
143 p "Domain #{domain}: Creating recursor object for checking challenge propagation"
144 rec = Dnsruby::Recursor.new
145 p "Domain #{domain}: Getting NS records for #{apex_domain}"
147 domain_auth_ns = rec.query_no_validation_or_recursion(apex_domain, 'NS')
148 rescue StandardError => e
149 warn "Domain #{domain}: NS lookup failed: #{e}"
153 p "Domain #{domain}: Checking challenge status on all NS"
157 domain_auth_ns.answer.each do |ns|
158 threads << Thread.new(ns) do |my_ns|
159 nameserver = my_ns.rdata.to_s
160 p "Domain #{domain}: Creating resolver object for checking propagation on #{nameserver}"
161 res = Dnsruby::Resolver.new(nameserver)
163 res.do_caching = false
165 p "Domain #{domain}: Querying ACME challenge record"
166 result = res.query_no_validation_or_recursion("_acme-challenge.#{domain}", 'TXT')
167 propagated = result.answer.any? do |answer|
168 answer.rdata[0] == challenge.record_content
172 p "Domain #{domain}: Not yet propagated, still old value, sleeping before checking again"
174 rescue Dnsruby::NXDomain
175 p "Domain #{domain}: Not yet propagated, NXdomain, sleeping before checking again"
178 rescue StandardError => e
179 warn "Domain #{domain}: ACME challenge lookup failed: #{e}"
188 def wait_for_challenge_validation(challenge, cert_name)
189 p 'Requesting validation of challenge'
192 challenge.request_validation
193 rescue Acme::Client::Error::BadNonce
195 p 'Retrying because of invalid nonce.'
196 retry if retries <= 5
199 while challenge.status == 'pending'
200 p "Cert #{cert_name}: Sleeping because challenge validation is pending"
206 rescue Acme::Client::Error::BadNonce
208 p 'Retrying because of invalid nonce.'
209 retry if retries <= 5
214 def get_cert(order, cert_name, domains, domain_key)
215 path = "./certs/#{cert_name}/"
216 crt_file = "#{path}/cert.pem"
217 p "Cert #{cert_name}: Creating CSR object"
218 csr = Acme::Client::CertificateRequest.new(
219 private_key: domain_key,
221 subject: { common_name: domains[0] }
223 p "Cert #{cert_name}: Finalize cert order"
227 rescue Acme::Client::Error::BadNonce
229 p 'Retrying because of invalid nonce.'
230 retry if retries <= 5
234 order.finalize(csr: csr)
235 rescue Acme::Client::Error::BadNonce
237 p 'Retrying because of invalid nonce.'
238 retry if retries <= 5
240 while order.status == 'processing'
241 p "Cert #{cert_name}: Sleep while order is processing"
243 p "Cert #{cert_name}: Rechecking order status"
247 rescue Acme::Client::Error::BadNonce
249 p 'Retrying because of invalid nonce.'
250 retry if retries <= 5
253 # p "order status: #{order.status}"
257 cert = order.certificate
258 rescue Acme::Client::Error::BadNonce
260 p 'Retrying because of invalid nonce.'
261 retry if retries <= 5
264 p "Cert #{cert_name}: Writing cert"
265 cert_file = File.new("#{path}#{Time.now.to_i}.crt", 'w')
266 cert_file.write(cert)
267 if File.symlink?("#{File.dirname(cert_file)}/current.crt")
268 File.unlink("#{File.dirname(cert_file)}/current.crt")
269 File.symlink(File.basename(cert_file), "#{File.dirname(cert_file)}/current.crt")
270 elsif File.file?("#{File.dirname(cert_file)}/current.crt")
271 raise 'Could not place symlink for "current.crt" because that is already a normal file.'
279 cert_dir = config.dig('global', 'cert_dir') || './certs/'
281 ensure_cert_dir(cert_dir)
284 # iterate over configured certs
285 # TODO: make this one thread per cert
286 # TODO: check all domains for apex domain, deploy challenges for one apex_domain all at once
287 config['certs'].each_pair do |cert_name, cert_opts|
288 acme_threads << Thread.new(cert_name, cert_opts) do |cert_name, cert_opts|
289 ensure_cert_dir(cert_dir + cert_name)
291 p "Cert #{cert_name}: Finding CA to use for cert"
292 cert_ca = cert_opts['ca'] || config.dig('defaults', 'certs', 'ca')
293 cert_ca_name = cert_ca['name']
294 cert_ca_account = cert_ca['account']
296 p "Cert #{cert_name}: Finding directory URL for CA"
297 acme_directory_url = config.dig('CAs', cert_ca_name, 'directory_url')
299 p "Cert #{cert_name}: Finding account to use for cert #{cert_name} from CA #{cert_ca_name}"
300 account = config.dig('ca_accounts', cert_ca_account)
301 email = account['email']
303 private_key = read_account_key(account['keyfile'])
305 p "Cert #{cert_name}: Creating client object for communication with CA"
306 client = Acme::Client.new(private_key: private_key, directory: acme_directory_url)
310 client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true)
311 rescue Acme::Client::Error::BadNonce
313 p 'Retrying because of invalid nonce.'
314 retry if retries <= 5
317 p "Cert #{cert_name}: Creating order object for cert #{cert_name}"
320 order = client.new_order(identifiers: cert_opts['domain_names'])
321 rescue Acme::Client::Error::BadNonce
323 p 'Retrying because of invalid nonce.'
324 retry if retries <= 5
327 p "Cert #{cert_name}: order status"
330 if order.status != 'ready'
331 p "Cert #{cert_name}: Order is not ready, we need to authorize first"
333 # TODO: collect dns modifications per primary NS, update all at once
334 p "Cert #{cert_name}: Iterating over required authorizations"
337 auths = order.authorizations
338 rescue Acme::Client::Error::BadNonce
340 p 'Retrying because of invalid nonce.'
341 retry if retries <= 5
344 p "Cert #{cert_name}: Processing authorization for #{auth.domain}"
345 p "Cert #{cert_name}: Finding challenge type for #{auth.domain}"
346 # p "Cert #{cert_name}: auth is:"
348 if auth.status == 'valid'
349 p "Cert #{cert_name}: Authorization for #{auth.domain} is still valid, skipping"
353 challenge = auth.dns01
354 primary_ns = config.dig('domains', auth.domain, 'primary_ns') || config.dig('defaults', 'domains', 'primary_ns')
355 deploy_dns01_challenge_token(auth.domain, challenge, primary_ns, config)
356 wait_for_challenge_propagation(auth.domain, challenge)
357 wait_for_challenge_validation(challenge, cert_name)
360 p "Cert #{cert_name}: Order is ready, we don’t need to authorize"
362 domain_key = read_cert_key(cert_name)
364 get_cert(order, cert_name, cert_opts['domain_names'], domain_key)
368 acme_threads.each(&:join)