]> git.netwichtig.de Git - user/henk/code/ruby/macir.git/blob - macir.rb
change account key file handling to be more robust and shorter
[user/henk/code/ruby/macir.git] / macir.rb
1 #!/usr/bin/ruby
2
3 # require 'net/http'
4 # require 'json'
5 require 'yaml'
6 require 'openssl'
7 require 'acme-client'
8 require 'dnsruby'
9 require 'time'
10
11
12 def read_config( path = 'config.yaml' )
13   p "Reading config from #{path}"
14   begin
15     config = YAML.load_file( path )
16   rescue Psych::SyntaxError
17     $stderr.puts "Parsing configfile failed: " + $!.to_s
18     raise
19   rescue Errno::ENOENT
20     $stderr.puts "IO failed: " + $!.to_s
21   end
22   return config
23 end
24
25 def read_account_key( path = 'pkey.pem' )
26   p "Reading account key from #{path}"
27   if File.readable?( path )
28     p "File #{path} is readable, trying to parse"
29     privatekey_string = File.read( path )
30     private_key = OpenSSL::PKey::EC.new( privatekey_string )
31   else
32     if File.exists?( path )
33       raise( "The file #{path} exists but is not readable. Make it readable or specify different path" )
34     else
35       p "File #{path} does not exist, trying to create"
36       private_key = OpenSSL::PKey::EC.generate( "prime256v1" )
37       File.write( path, private_key.private_to_pem )
38     end
39   end
40   return private_key
41 end
42
43 def deploy_dns01_challenge_token( domain, token, nameserver, config )
44   p "Creating DNS UPDATE packet"
45   update = Dnsruby::Update.new( domain )
46   # TODO: delete challenge token record after validation
47   update.delete( "_acme-challenge." + domain , 'TXT' )
48   update.add( "_acme-challenge." + domain, 'TXT', 10, token )
49
50   p "Creating object for contacting nameserver"
51   res = Dnsruby::Resolver.new( nameserver )
52   res.dnssec = false
53
54   p "Looking up TSIG parameters"
55   tsig_name = config['domains'][domain]['tsig_key']
56   tsig_key = config['tsig_keys'][tsig_name]['key']
57   tsig_alg = config['tsig_keys'][tsig_name]['algorithm']
58   p "Creating TSIG object"
59   tsig = Dnsruby::RR.create({
60     :name      => tsig_name,
61     :type      => 'TSIG',
62     :key       => tsig_key,
63     :algorithm => tsig_alg,
64   })
65
66   p "Signing DNS UPDATE packet with TSIG object"
67   tsig.apply(update)
68
69   p "Sending UPDATE to nameserver"
70   response = res.send_message(update)
71 end
72
73 def wait_for_challenge_propagation( domain, challenge )
74   p "Creating recursor object for checking challenge propagation"
75   rec = Dnsruby::Recursor.new
76   p "Getting NS records for #{domain}"
77   domain_auth_ns = rec.query_no_validation_or_recursion( domain, "NS" )
78
79   p "Checking challenge status on all NS"
80   domain_auth_ns.answer.each do |ns|
81     nameserver = ns.rdata.to_s
82     p "Creating resolver object for checking propagation on #{nameserver}"
83     res = Dnsruby::Resolver.new( nameserver )
84     res.dnssec = false
85     res.do_caching = false
86     begin
87       p "Querying ACME challenge record"
88       result = res.query_no_validation_or_recursion( "_acme-challenge." + domain, "TXT" )
89       p result
90       propagated = result.answer.any? do |answer|
91         p "Checking response"
92         p answer
93         p answer.rdata[0]
94         p "against challenge string"
95         p challenge.record_content
96         answer.rdata[0] == challenge.record_content
97       end
98       unless propagated
99         p "Sleeping before checking again"
100         sleep(1)
101       end
102     end until propagated
103   end
104 end
105
106 def wait_for_challenge_validation( challenge )
107   p "Requesting validation of challenge"
108   challenge.request_validation
109
110   while challenge.status == 'pending'
111     p "Sleeping because challenge validation is pending"
112     sleep(1)
113     p "Checking again"
114     challenge.reload
115   end
116 end
117
118 def get_cert_key( domain )
119   path = "./domains/#{domain}/"
120   key_file = path + "current.key"
121   p "Reading cert key from #{key_file}"
122   if File.readable?( key_file )
123     p "Cert key is readable, trying to read"
124     pkey_file = File.new( key_file )
125     privatekey_string = pkey_file.read
126     domain_key = OpenSSL::PKey::EC.new( privatekey_string )
127   else
128     p "Cert key is not readable, trying to create one"
129     pkey_file = File.new( path + Time.now.to_i.to_s + ".key", 'w' )
130     domain_key = OpenSSL::PKey::EC.generate( "prime256v1" )
131     pkey_pem = domain_key.private_to_pem
132     pkey_file.write( pkey_pem )
133     File.symlink( File.basename( pkey_file ), File.dirname( pkey_file ) + "/current.key" )
134   end
135   return domain_key
136 end
137
138 def get_cert( order, domains, domain_key )
139   path = "./domains/#{domains[0]}/"
140   crt_file = path + "cert.pem"
141   p "Creating CSR object"
142   csr = Acme::Client::CertificateRequest.new(private_key: domain_key, names: domains, subject: { common_name: "#{domains[0]}" })
143   p "Finalize cert order"
144   order.finalize(csr: csr)
145   while order.status == 'processing'
146     p "Sleep while order is processing"
147     sleep(1)
148     p "Rechecking order status"
149     order.reload
150   end
151   cert = order.certificate
152
153   p "Writing cert"
154   cert_file = File.new( path + Time.now.to_i.to_s + ".crt", 'w' )
155   cert_file.write( cert )
156   if File.symlink?( File.dirname( cert_file ) + "/current.crt" ) then
157     File.unlink( File.dirname( cert_file ) + "/current.crt" )
158     File.symlink( File.basename( cert_file ), File.dirname( cert_file ) + "/current.crt" )
159   else
160     raise Exception
161   end
162   return cert
163 end
164
165
166 config = read_config
167
168 # iterate over configured certs
169 # TODO: make this one thread per cert
170 config['certs'].each_pair do |cert_name, cert_opts|
171   p "Finding CA to use for cert #{cert_name}"
172   acme_directory_url = config['CAs'][cert_opts['ca']['name']]['directory_url']
173
174   p "Finding account to use for cert #{cert_name} from CA #{cert_opts['ca']['name']}"
175   account = config['ca_accounts'][cert_opts['ca']['account']]
176   email = account['email']
177
178   private_key = read_account_key( account['keyfile'] )
179
180   p "Creating client object for communication with CA"
181   client = Acme::Client.new( private_key: private_key, directory: acme_directory_url )
182
183   client.new_account(contact: "mailto:#{email}", terms_of_service_agreed: true)
184
185   p "Creating order object for cert #{cert_name}"
186   order = client.new_order(identifiers: cert_opts['domain_names'] )
187   if order.status != "ready" then
188     p "Order is not ready, we need to authorize first"
189
190     p "Iterating over required authorizations"
191     order.authorizations.each do |auth|
192       p "Processing authorization for #{auth.domain}"
193       p "Finding challenge type for #{auth.domain}"
194       p config['domains'][auth.domain]['challenge']
195       challenge = auth.dns01
196       deploy_dns01_challenge_token( auth.domain, challenge.record_content, config['domains'][auth.domain]['primary_ns'], config )
197       wait_for_challenge_propagation( auth.domain, challenge )
198       wait_for_challenge_validation( challenge )
199     end
200
201     # deploy_dns01_challenge_token( cert_opts['domain_names'][0], challenge.record_content, cert_opts['challenge']['primary_ns'], config )
202
203   else
204     p "Order is ready, we don’t need to authorize"
205   end
206   domain_key = get_cert_key( cert_opts['domain_names'][0] )
207
208   get_cert( order, cert_opts['domain_names'], domain_key )
209 end