]> git.netwichtig.de Git - user/henk/code/ruby/macir.git/blob - macir.rb
change: format due to linter warning; sleeping time to be faster
[user/henk/code/ruby/macir.git] / macir.rb
1 #!/usr/bin/ruby
2
3 # frozen_string_literal: true
4
5 require 'yaml'
6 require 'openssl'
7 require 'acme-client'
8 require 'dnsruby'
9 require 'time'
10 require 'English'
11
12
13 def read_config(path = 'config.yaml')
14   p "Reading config from #{path}"
15   YAML.load_file(path)
16 rescue Psych::SyntaxError => e
17   warn "Parsing configfile failed: #{e}"
18   raise
19 rescue Errno::ENOENT => e
20   warn "IO failed: #{e}"
21   raise
22 end
23
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)
28   end
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.')
33 end
34
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")
43   else
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)
47   end
48   return private_key
49 end
50
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")
61   else
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")
67   end
68   return private_key
69 end
70
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}"
77   raise
78 end
79
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
84   else
85     domain_soa_resp.answer[0].name
86   end
87 end
88
89 def deploy_dns01_challenge_token(domain, challenge, nameserver, config)
90   p "Domain #{domain}: Creating DNS UPDATE packet"
91
92   apex_domain = find_apex_domain(domain)
93
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)
98
99   p "Domain #{domain}: Creating object for contacting nameserver"
100   res = Dnsruby::Resolver.new(nameserver)
101   res.dnssec = false
102
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')
107
108   p "Domain #{domain}: Creating TSIG object"
109   tsig = Dnsruby::RR.create(
110     {
111       name: tsig_name,
112       type: 'TSIG',
113       key: tsig_key,
114       algorithm: tsig_alg,
115     }
116   )
117
118   p "Domain #{domain}: Signing DNS UPDATE packet with TSIG object"
119   tsig.apply(update)
120
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}"
125   raise
126 end
127
128 def wait_for_challenge_propagation(domain, challenge)
129   rec = Dnsruby::Recursor.new
130   p "Domain #{domain}: Getting SOA records for #{domain}"
131   begin
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}"
135     raise
136   end
137   apex_domain = if domain_soa_resp.answer.empty?
138                   domain_soa_resp.authority[0].name
139                 else
140                   domain_soa_resp.answer[0].name
141                 end
142
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}"
146   begin
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}"
150     raise
151   end
152
153   p "Domain #{domain}: Checking challenge status on all NS"
154
155   threads = []
156
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)
162       res.dnssec = false
163       res.do_caching = false
164       loop do
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
169         end
170         break if propagated
171
172         p "Domain #{domain}: Not yet propagated, still old value, sleeping before checking again"
173         sleep(0.5)
174       rescue Dnsruby::NXDomain
175         p "Domain #{domain}: Not yet propagated, NXdomain, sleeping before checking again"
176         sleep(0.5)
177         retry
178       rescue StandardError => e
179         warn "Domain #{domain}: ACME challenge lookup failed: #{e}"
180         raise
181       end
182     end
183   end
184
185   threads.each(&:join)
186 end
187
188 def wait_for_challenge_validation(challenge, cert_name)
189   p 'Requesting validation of challenge'
190   begin
191     retries ||= 0
192     challenge.request_validation
193   rescue Acme::Client::Error::BadNonce
194     retries += 1
195     p 'Retrying because of invalid nonce.'
196     retry if retries <= 5
197   end
198
199   while challenge.status == 'pending'
200     p "Cert #{cert_name}: Sleeping because challenge validation is pending"
201     sleep(0.1)
202     p 'Checking again'
203     begin
204       retries ||= 0
205       challenge.reload
206     rescue Acme::Client::Error::BadNonce
207       retries += 1
208       p 'Retrying because of invalid nonce.'
209       retry if retries <= 5
210     end
211   end
212 end
213
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,
220     names: domains,
221     subject: { common_name: domains[0] }
222   )
223   p "Cert #{cert_name}: Finalize cert order"
224   begin
225     retries ||= 0
226     order.reload
227   rescue Acme::Client::Error::BadNonce
228     retries += 1
229     p 'Retrying because of invalid nonce.'
230     retry if retries <= 5
231   end
232   begin
233     retries ||= 0
234     order.finalize(csr: csr)
235   rescue Acme::Client::Error::BadNonce
236     retries += 1
237     p 'Retrying because of invalid nonce.'
238     retry if retries <= 5
239   end
240   while order.status == 'processing'
241     p "Cert #{cert_name}: Sleep while order is processing"
242     sleep(0.1)
243     p "Cert #{cert_name}: Rechecking order status"
244     begin
245       retries ||= 0
246       order.reload
247     rescue Acme::Client::Error::BadNonce
248       retries += 1
249       p 'Retrying because of invalid nonce.'
250       retry if retries <= 5
251     end
252   end
253   # p "order status: #{order.status}"
254   # pp order
255   begin
256     retries ||= 0
257     cert = order.certificate
258   rescue Acme::Client::Error::BadNonce
259     retries += 1
260     p 'Retrying because of invalid nonce.'
261     retry if retries <= 5
262   end
263
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.'
272   end
273   return cert
274 end
275
276
277 config = read_config
278
279 cert_dir = config.dig('global', 'cert_dir') || './certs/'
280
281 ensure_cert_dir(cert_dir)
282
283 acme_threads = []
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)
290
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']
295
296     p "Cert #{cert_name}: Finding directory URL for CA"
297     acme_directory_url = config.dig('CAs', cert_ca_name, 'directory_url')
298
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']
302
303     private_key = read_account_key(account['keyfile'])
304
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)
307
308     begin
309       retries ||= 0
310       client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true)
311     rescue Acme::Client::Error::BadNonce
312       retries += 1
313       p 'Retrying because of invalid nonce.'
314       retry if retries <= 5
315     end
316
317     p "Cert #{cert_name}: Creating order object for cert #{cert_name}"
318     begin
319       retries ||= 0
320       order = client.new_order(identifiers: cert_opts['domain_names'])
321     rescue Acme::Client::Error::BadNonce
322       retries += 1
323       p 'Retrying because of invalid nonce.'
324       retry if retries <= 5
325     end
326
327     p "Cert #{cert_name}: order status"
328     p order.status
329
330     if order.status != 'ready'
331       p "Cert #{cert_name}: Order is not ready, we need to authorize first"
332
333       # TODO: collect dns modifications per primary NS, update all at once
334       p "Cert #{cert_name}: Iterating over required authorizations"
335       begin
336         retries ||= 0
337         auths = order.authorizations
338       rescue Acme::Client::Error::BadNonce
339         retries += 1
340         p 'Retrying because of invalid nonce.'
341         retry if retries <= 5
342       end
343       auths.each do |auth|
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:"
347         # pp auth
348         if auth.status == 'valid'
349           p "Cert #{cert_name}: Authorization for #{auth.domain} is still valid, skipping"
350           next
351         end
352
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)
358       end
359     else
360       p "Cert #{cert_name}: Order is ready, we don’t need to authorize"
361     end
362     domain_key = read_cert_key(cert_name)
363
364     get_cert(order, cert_name, cert_opts['domain_names'], domain_key)
365   end
366 end
367
368 acme_threads.each(&:join)