]> git.netwichtig.de Git - user/henk/code/ruby/macir.git/blob - macir.rb
3c8ca0c1da6069e93e985c7a850149cc2f6f07ad
[user/henk/code/ruby/macir.git] / macir.rb
1 #!/usr/bin/ruby
2
3 require 'yaml'
4 require 'openssl'
5 require 'acme-client'
6 require 'dnsruby'
7 require 'time'
8
9
10 def read_config( path = 'config.yaml' )
11   p "Reading config from #{path}"
12   begin
13     config = YAML.load_file( path )
14   rescue Psych::SyntaxError
15     $stderr.puts "Parsing configfile failed: " + $!.to_s
16     raise
17   rescue Errno::ENOENT
18     $stderr.puts "IO failed: " + $!.to_s
19     raise
20   end
21   return config
22 end
23
24 def ensure_cert_dir( path = './certs' )
25   if not File.exist?( path )
26     puts 'Certificate directory does not exist. Creating with secure permissions.'
27     Dir.mkdir( path, 0700 )
28     File.chmod( 0700, path )
29   end
30   if File.world_writable?( path )
31     $stderr.puts "WARNING! Certificate directory is world writable! This could be a serious security issue!"
32   end
33   if File.world_readable?( path )
34     $stderr.puts "WARNING! Certificate directory is world readable! This could be a serious security issue!"
35   end
36   if File.file?( path )
37     raise( 'Certificate directory is not a directory but a file. Aborting.' )
38   end
39   if not File.writable?( path )
40     raise( 'Certificate directory is not writable. Aborting.' )
41   end
42 end
43
44 def read_account_key( path = 'pkey.pem' )
45   p "Reading account key from #{path}"
46   if File.readable?( path )
47     p "File #{path} is readable, trying to parse"
48     privatekey_string = File.read( path )
49     private_key = OpenSSL::PKey::EC.new( privatekey_string )
50   else
51     if File.exists?( path )
52       raise( "The file #{path} exists but is not readable. Make it readable or specify different path" )
53     else
54       p "File #{path} does not exist, trying to create"
55       private_key = OpenSSL::PKey::EC.generate( "prime256v1" )
56       File.write( path, private_key.private_to_pem )
57     end
58   end
59   return private_key
60 end
61
62 def read_cert_key( cert_name )
63   folder = "./certs/#{cert_name}/"
64   path = "#{folder}/current.key"
65   p "cert_name #{cert_name}: Reading cert key from #{path}"
66   if File.readable?( path )
67     p "cert_name #{cert_name}: File #{path} is readable, trying to parse"
68     privatekey_string = File.read( path )
69     private_key = OpenSSL::PKey::EC.new( privatekey_string )
70   else
71     if File.exists?( path )
72       raise( "cert_name #{cert_name}: The file #{path} exists but is not readable. Make it readable or specify different path" )
73     else
74       p "cert_name #{cert_name}: File #{path} does not exist, trying to create"
75       private_key = OpenSSL::PKey::EC.generate( "prime256v1" )
76       pkey_file = File.new( folder + Time.now.to_i.to_s + ".key", 'w' )
77       pkey_file.write( private_key.private_to_pem )
78       File.symlink( File.basename( pkey_file ), File.dirname( pkey_file ) + "/current.key" )
79     end
80   end
81   return private_key
82 end
83
84 def deploy_dns01_challenge_token( domain, challenge, nameserver, config )
85   p "Domain #{domain}: Creating DNS UPDATE packet"
86   update = Dnsruby::Update.new( domain )
87   # TODO: delete challenge token record after validation
88   update.delete( challenge.record_name + "." + domain, challenge.record_type )
89   update.add( challenge.record_name + "." + domain, challenge.record_type, 10, challenge.record_content )
90
91   p "Domain #{domain}: Creating object for contacting nameserver"
92   res = Dnsruby::Resolver.new( nameserver )
93   res.dnssec = false
94
95   p "Domain #{domain}: Looking up TSIG parameters"
96   tsig_name = config.dig( 'domains', domain, 'tsig_key' ) || config.dig( 'defaults', 'domains', 'tsig_key' )
97   tsig_key = config.dig( 'tsig_keys', tsig_name, 'key' )
98   tsig_alg = config.dig( 'tsig_keys', tsig_name, 'algorithm' )
99   p tsig_name
100   p tsig_key
101   p tsig_alg
102
103   p "Domain #{domain}: Creating TSIG object"
104   tsig = Dnsruby::RR.create(
105     {
106       name: tsig_name,
107       type: 'TSIG',
108       key: tsig_key,
109       algorithm: tsig_alg,
110     }
111   )
112
113   p "Domain #{domain}: Signing DNS UPDATE packet with TSIG object"
114   tsig.apply(update)
115   p update
116
117   p "Domain #{domain}: Sending UPDATE to nameserver"
118   begin
119     response = res.send_message(update)
120   rescue Exception
121     $stderr.puts "Domain #{domain}: IO failed: " + $!.to_s
122     raise
123   end
124 end
125
126 def wait_for_challenge_propagation( domain, challenge )
127   p "Domain #{domain}: Creating recursor object for checking challenge propagation"
128   rec = Dnsruby::Recursor.new
129   p "Domain #{domain}: Getting NS records for #{domain}"
130   domain_auth_ns = rec.query_no_validation_or_recursion( domain, "NS" )
131
132   p "Domain #{domain}: Checking challenge status on all NS"
133
134   threads = []
135
136   domain_auth_ns.answer.each do |ns|
137     threads << Thread.new(ns) do |my_ns|
138       nameserver = my_ns.rdata.to_s
139       p "Domain #{domain}: Creating resolver object for checking propagation on #{nameserver}"
140       res = Dnsruby::Resolver.new( nameserver )
141       res.dnssec = false
142       res.do_caching = false
143       begin
144         p "Domain #{domain}: Querying ACME challenge record"
145         result = res.query_no_validation_or_recursion( "_acme-challenge." + domain, "TXT" )
146         # p result
147         propagated = result.answer.any? do |answer|
148           answer.rdata[0] == challenge.record_content
149         end
150         unless propagated
151           p "Domain #{domain}: Not yet propagated, sleeping before checking again"
152           sleep(1)
153         end
154       end until propagated
155     end
156   end
157
158   threads.each { |thread| thread.join }
159 end
160
161 def wait_for_challenge_validation( challenge )
162   p 'Requesting validation of challenge'
163   challenge.request_validation
164
165   while challenge.status == 'pending'
166     p 'Sleeping because challenge validation is pending'
167     sleep(1)
168     p 'Checking again'
169     challenge.reload
170   end
171 end
172
173 def get_cert( order, cert_name, domains, domain_key )
174   path = "./certs/#{cert_name}/"
175   crt_file = "#{path}/cert.pem"
176   p "Cert #{cert_name}: Creating CSR object"
177   csr = Acme::Client::CertificateRequest.new(
178     private_key: domain_key,
179     names: domains,
180     subject: { common_name: "#{domains[0]}" }
181   )
182   p "Cert #{cert_name}: Finalize cert order"
183   order.finalize(csr: csr)
184   while order.status == 'processing'
185     p "Cert #{cert_name}: Sleep while order is processing"
186     sleep(1)
187     p "Cert #{cert_name}: Rechecking order status"
188     order.reload
189   end
190   cert = order.certificate
191
192   p "Cert #{cert_name}: Writing cert"
193   cert_file = File.new( path + Time.now.to_i.to_s + ".crt", 'w' )
194   cert_file.write( cert )
195   if File.symlink?( File.dirname( cert_file ) + "/current.crt" )
196     File.unlink( File.dirname( cert_file ) + "/current.crt" )
197     File.symlink( File.basename( cert_file ), File.dirname( cert_file ) + "/current.crt" )
198   elsif File.file?( File.dirname( cert_file ) + "/current.crt" )
199     raise 'Could not place symlink for "current.crt" because that is already a normal file.'
200   end
201   return cert
202 end
203
204
205 config = read_config
206
207 cert_dir = config.dig('global', 'cert_dir') || './certs/'
208
209 ensure_cert_dir( cert_dir )
210
211 acme_threads = []
212 # iterate over configured certs
213 # TODO: make this one thread per cert
214 config['certs'].each_pair do |cert_name, cert_opts|
215   acme_threads << Thread.new(cert_name, cert_opts) do |cert_name, cert_opts|
216     ensure_cert_dir( cert_dir + cert_name )
217
218     p "Cert #{cert_name}: Finding CA to use for cert"
219     cert_ca = cert_opts['ca'] || config.dig( 'defaults', 'certs', 'ca' )
220     cert_ca_name = cert_ca['name']
221     cert_ca_account = cert_ca['account']
222
223     p "Cert #{cert_name}: Finding directory URL for CA"
224     acme_directory_url = config.dig( 'CAs', cert_ca_name, 'directory_url' )
225
226     p "Cert #{cert_name}: Finding account to use for cert #{cert_name} from CA #{cert_ca_name}"
227     account = config.dig( 'ca_accounts', cert_ca_account )
228     email = account['email']
229
230     private_key = read_account_key( account['keyfile'] )
231
232     p "Cert #{cert_name}: Creating client object for communication with CA"
233     client = Acme::Client.new( private_key: private_key, directory: acme_directory_url )
234
235     client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true)
236
237     p "Cert #{cert_name}: Creating order object for cert #{cert_name}"
238     order = client.new_order(identifiers: cert_opts['domain_names'] )
239     p "Cert #{cert_name}: order status"
240     p order.status
241     if order.status != 'ready'
242       p "Cert #{cert_name}: Order is not ready, we need to authorize first"
243
244       p "Cert #{cert_name}: Iterating over required authorizations"
245       order.authorizations.each do |auth|
246         p "Cert #{cert_name}: Processing authorization for #{auth.domain}"
247         p "Cert #{cert_name}: Finding challenge type for #{auth.domain}"
248         if auth.status == 'valid'
249           p "Cert #{cert_name}: Authorization for #{auth.domain} is still valid, skipping"
250           next
251         end
252
253         challenge = auth.dns01
254         primary_ns = config.dig( 'domains', auth.domain, 'primary_ns' ) || config.dig( 'defaults', 'domains', 'primary_ns' )
255         deploy_dns01_challenge_token( auth.domain, challenge, primary_ns, config )
256         wait_for_challenge_propagation( auth.domain, challenge )
257         wait_for_challenge_validation( challenge )
258       end
259     else
260       p "Cert #{cert_name}: Order is ready, we don’t need to authorize"
261     end
262     domain_key = read_cert_key( cert_name )
263
264     get_cert( order, cert_name, cert_opts['domain_names'], domain_key )
265   end
266 end
267
268 acme_threads.each { |thread| thread.join }