letscert 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 93eea77b95f4023766df113b6fcf0af05cac41f7
4
- data.tar.gz: eefe4a0c05303c3c3b7d6cd554aa518b73d3da43
3
+ metadata.gz: 7794f56c134e1437cbb2d89cf9bcf3b2da6f5bc1
4
+ data.tar.gz: 735bb555113c95eb110500c3fcd43b435c7e41aa
5
5
  SHA512:
6
- metadata.gz: 308c8ddba083a0622f81e213fcd31133b5b8161b8571ee437a05cf4bd5695cef031f0beec14945361b197822a075727e57264e12a5ecdbf49d3e23862401f74b
7
- data.tar.gz: b4bded7e572fa89fb693d2f511a002a0e1f523272ffeaa0bb3de441bc15dcad2b4de2cee6ebfad35a441fe7d0bfa7796e0b9166cd4c693192fc30762dfac5355
6
+ metadata.gz: 8d1369f91acfe830d02461a442eeac66d14995944996dac69c1d6120c42077566701eb0aca6bdc34ec9a0a17e82d2765a315086ba5ad0d97b7c58772701e0102
7
+ data.tar.gz: 5cc07fe0b35bab4b652ac29a1807ae39b62cae562fcbb73dd8fc9320f2bbef3be338471094baca01bdc41c9309fb6c8b375080b01013c91538467ecaddbae05b
data/README.md CHANGED
@@ -1,10 +1,9 @@
1
1
  # letscert
2
2
  A simple `Let's Encrypt` client in ruby.
3
3
 
4
- I think `simp_le` do it the right way: it is simple, it is safe as it does not needed to be run as root,
5
- but it is Python (no one is perfect :-)) So I started to create a clone, but in Ruby.
6
-
7
- Work in progress.
4
+ I think `simp_le` do it the right way: it is simple, it is safe as it does not need to be
5
+ run as root, but it is Python (no one is perfect :-)) So I started to create a clone, but
6
+ in Ruby.
8
7
 
9
8
  # Usage
10
9
 
@@ -14,3 +13,21 @@ letscert -d example.com:/var/www/example.com/html -f key.pem -f cert.pem -f full
14
13
  ```
15
14
 
16
15
  The command is the same for certificate renewal.
16
+
17
+ # What `letscert` do
18
+
19
+ * Automagically a new ACME account if needed.
20
+ * Issue new certificate if no previous one found.
21
+ * Renew certificate only if needed.
22
+ * Only `http-01` challenge supported. An existing web server must be alreay running. `letscert` should have write access to `${webroot}/.well-known/acme-challenge`.
23
+ * Crontab friendly: no promts.
24
+ * No configuration file.
25
+ * Support multiple domains with multiple roots. Always create a single certificate per un
26
+ (ie a certificate may have multiple SANs).
27
+ * As `simp_le`, check the exit code to known if a renewal has happened:
28
+ * 0 if certificate data was created or updated;
29
+ * 1 if renewal not necessary;
30
+ * 2 in case of errors.
31
+
32
+ # Todo
33
+ Add support to revocation.
data/bin/letscert CHANGED
@@ -1,4 +1,26 @@
1
1
  #!/usr/bin/ruby
2
+ #
3
+ # The MIT License (MIT)
4
+ #
5
+ # Copyright (c) 2016 Sylvain Daubert
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"), to deal
9
+ # in the Software without restriction, including without limitation the rights
10
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ # copies of the Software, and to permit persons to whom the Software is
12
+ # furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in all
15
+ # copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ # SOFTWARE.
2
24
 
3
25
  require 'acme-client'
4
26
  require 'letscert'
@@ -0,0 +1,209 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2016 Sylvain Daubert
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+ require_relative 'loggable'
23
+
24
+ module LetsCert
25
+
26
+ # Class to handle ACME operations on certificates
27
+ class Certificate
28
+ include Loggable
29
+
30
+ # Revoke certificates
31
+ # @param [Array<String>] files
32
+ def self.revoke(files)
33
+ logger.warn "revoke not yet implemented"
34
+ end
35
+
36
+ # Get a new certificate, or renew an existing one
37
+ # @param [Hash] options
38
+ # @param [Hash] data
39
+ def self.get(options, data)
40
+ new.get options, data
41
+ end
42
+
43
+ def get(options, data)
44
+ logger.info {"create key/cert/chain..." }
45
+ roots = compute_roots(options)
46
+ logger.debug { "webroots are: #{roots.inspect}" }
47
+
48
+ client = get_acme_client(data[:account_key], options)
49
+
50
+ do_challenges client, roots
51
+
52
+ if options[:reuse_key] and !data[:key].nil?
53
+ logger.info { 'Reuse existing private key' }
54
+ key = data[:key]
55
+ else
56
+ logger.info { 'Generate new private key' }
57
+ key = OpenSSL::PKey::RSA.generate(@options[:cert_key_size])
58
+ end
59
+
60
+ csr = Acme::Client::CertificateRequest.new(names: roots.keys, private_key: key)
61
+ cert = client.new_certificate(csr)
62
+
63
+ IOPlugin.registered.each do |name, plugin|
64
+ plugin.save( account_key: client.private_key, key: key, cert: cert.x509,
65
+ chain: cert.x509_chain)
66
+ end
67
+ end
68
+
69
+
70
+ private
71
+
72
+ # Compute webroots
73
+ # @return [Hash] whre key are domains and value are their webroot path
74
+ def compute_roots(options)
75
+ roots = {}
76
+ no_roots = []
77
+
78
+ options[:domains].each do |domain|
79
+ match = domain.match(/([\w+\.]+):(.*)/)
80
+ if match
81
+ roots[match[1]] = match[2]
82
+ elsif options[:default_root]
83
+ roots[domain] = options[:default_root]
84
+ else
85
+ no_roots << domain
86
+ end
87
+ end
88
+
89
+ if !no_roots.empty?
90
+ raise Error, 'root for the following domain(s) are not specified: ' +
91
+ no_roots.join(', ') + ".\nTry --default_root or use " +
92
+ '-d example.com:/var/www/html syntax.'
93
+ end
94
+
95
+ roots
96
+ end
97
+
98
+ # Get ACME client.
99
+ #
100
+ # Client is only created on first call, then it is cached.
101
+ # @param [Hash] account_key
102
+ # @param [Hash] options
103
+ def get_acme_client(account_key, options)
104
+ return @client if @client
105
+
106
+ key = get_account_key(account_key, options[:account_key_size])
107
+
108
+ logger.debug { "connect to #{options[:server]}" }
109
+ @client = Acme::Client.new(private_key: key, endpoint: options[:server])
110
+
111
+ if options[:email].nil?
112
+ logger.warn { '--email was not provided. ACME CA will have no way to ' +
113
+ 'contact you!' }
114
+ end
115
+
116
+ begin
117
+ logger.debug { "register with #{options[:email]}" }
118
+ registration = @client.register(contact: "mailto:#{options[:email]}")
119
+ rescue Acme::Client::Error::Malformed => ex
120
+ if ex.message != 'Registration key is already in use'
121
+ raise
122
+ end
123
+ else
124
+ # Requesting ToS make acme-client throw an exception: Connection reset by peer
125
+ # (Faraday::ConnectionFailed). To investigate...
126
+ #if registration.term_of_service_uri
127
+ # @logger.debug { "get terms of service" }
128
+ # terms = registration.get_terms
129
+ # if !terms.nil?
130
+ # tos_digest = OpenSSL::Digest::SHA256.digest(terms)
131
+ # if tos_digest != @options[:tos_sha256]
132
+ # raise Error, 'Terms Of Service mismatch'
133
+ # end
134
+
135
+ @logger.debug { "agree terms of service" }
136
+ registration.agree_terms
137
+ # end
138
+ #end
139
+ end
140
+
141
+ @client
142
+ end
143
+
144
+ # Generate a new account key if no one is given in +data+
145
+ # @param [OpenSSL::PKey,nil] key
146
+ # @param [Hash] options
147
+ def get_account_key(key, key_size)
148
+ if key.nil?
149
+ logger.info { 'No account key. Generate a new one...' }
150
+ OpenSSL::PKey::RSA.new(key_size)
151
+ else
152
+ key
153
+ end
154
+ end
155
+
156
+ # Do ACME challenges for each requested domain.
157
+ # @param [Acme::Client] client
158
+ # @param [Hash] roots
159
+ def do_challenges(client, roots)
160
+ logger.debug { 'Get authorization for all domains' }
161
+ challenges = {}
162
+
163
+ roots.keys.each do |domain|
164
+ authorization = client.authorize(domain: domain)
165
+ if authorization
166
+ challenges[domain] = authorization.http01
167
+ else
168
+ challenges[domain] = nil
169
+ end
170
+ end
171
+
172
+ logger.debug { 'Check all challenges are HTTP-01' }
173
+ if challenges.values.any? { |chall| chall.nil? }
174
+ raise Error, 'CA did not offer http-01-only challenge. ' +
175
+ 'This client is unable to solve any other challenges.'
176
+ end
177
+
178
+ challenges.each do |domain, challenge|
179
+ begin
180
+ FileUtils.mkdir_p(File.join(roots[domain], File.dirname(challenge.filename)))
181
+ rescue SystemCallError => ex
182
+ raise Error, ex.message
183
+ end
184
+
185
+ path = File.join(roots[domain], challenge.filename)
186
+ logger.debug { "Save validation #{challenge.file_content} to #{path}" }
187
+ File.write path, challenge.file_content
188
+
189
+ challenge.request_verification
190
+
191
+ status = 'pending'
192
+ while(status == 'pending') do
193
+ sleep(1)
194
+ status = challenge.verify_status
195
+ end
196
+
197
+ if status != 'valid'
198
+ logger.warn { "#{domain} was not successfully verified!" }
199
+ else
200
+ logger.info { "#{domain} was successfully verified." }
201
+ end
202
+
203
+ File.unlink path
204
+ end
205
+ end
206
+
207
+ end
208
+
209
+ end
@@ -1,14 +1,8 @@
1
+
1
2
  module LetsCert
2
3
 
3
- # Class to handle certificates.
4
+ # Class to handle ACME operations on certificates
4
5
  class Certificate
5
-
6
- # Load all certificates passed as arguments
7
- # @param [Array<String>] files
8
- # @return [Array<Certificate>]
9
- def self.load(*files)
10
- end
11
-
12
6
  end
13
7
 
14
8
  end
@@ -1,10 +1,34 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2016 Sylvain Daubert
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
1
22
  require 'json'
2
23
  require 'base64'
24
+ require_relative 'loggable'
3
25
 
4
26
  module LetsCert
5
27
 
6
28
  # Input/output plugin
29
+ # @author Sylvain Daubert
7
30
  class IOPlugin
31
+ include Loggable
8
32
 
9
33
  # Plugin name
10
34
  # @return [String]
@@ -15,18 +39,23 @@ module LetsCert
15
39
  %w(fullchain.pem key.der key.pem)
16
40
 
17
41
 
42
+ # Registered plugins
18
43
  @@registered = {}
19
44
 
20
45
  # Get empty data
46
+ # @return [Hash] +{ account_key: nil, key: nil, cert: nil, chain: nil }+
21
47
  def self.empty_data
22
48
  { account_key: nil, key: nil, cert: nil, chain: nil }
23
49
  end
24
50
 
25
51
  # Register a plugin
52
+ # @param [Class] klass
53
+ # @param [Array] args args to pass to +klass+ constructor
54
+ # @return [IOPlugin]
26
55
  def self.register(klass, *args)
27
56
  plugin = klass.new(*args)
28
57
  if plugin.name =~ /[\/\\]/ or ['.', '..'].include?(plugin.name)
29
- raise Error, "plugin name should just ne a file name, without path"
58
+ raise Error, "plugin name should just be a file name, without path"
30
59
  end
31
60
 
32
61
  @@registered[plugin.name] = plugin
@@ -35,26 +64,16 @@ module LetsCert
35
64
  end
36
65
 
37
66
  # Get registered plugins
67
+ # @return [Hash] keys are filenames and keys are instances of IOPlugin subclasses.
38
68
  def self.registered
39
69
  @@registered
40
70
  end
41
71
 
42
- # Set logger
43
- def self.logger=(logger)
44
- @@logger = logger
45
- end
46
-
47
72
  # @param [String] name
48
73
  def initialize(name)
49
74
  @name = name
50
75
  end
51
76
 
52
- # Get logger instance
53
- # @return [Logger]
54
- def logger
55
- @logger ||= self.class.class_variable_get(:@@logger)
56
- end
57
-
58
77
  # @abstract This method must be overriden in subclasses
59
78
  def load
60
79
  raise NotImplementedError
@@ -69,6 +88,7 @@ module LetsCert
69
88
 
70
89
 
71
90
  # Mixin for IOPmugin subclasses that handle files
91
+ # @author Sylvain Daubert
72
92
  module FileIOPluginMixin
73
93
 
74
94
  # Load data from file named {#name}
@@ -116,6 +136,7 @@ module LetsCert
116
136
 
117
137
 
118
138
  # Mixin for IOPlugin subclasses that handle JWK
139
+ # @author Sylvain Daubert
119
140
  module JWKIOPluginMixin
120
141
 
121
142
  # Load crypto data from JSON-encoded file
@@ -166,18 +187,24 @@ module LetsCert
166
187
 
167
188
 
168
189
  # Account key IO plugin
190
+ # @author Sylvain Daubert
169
191
  class AccountKey < IOPlugin
170
192
  include FileIOPluginMixin
171
193
  include JWKIOPluginMixin
172
194
 
195
+ # @return [Hash] always get +true+ for +:account_key+ key
173
196
  def persisted
174
197
  { account_key: true }
175
198
  end
176
199
 
200
+ # @return [Hash]
177
201
  def load_from_content(content)
178
202
  { account_key: load_jwk(content) }
179
203
  end
180
204
 
205
+ # Save account key.
206
+ # @param [Hash] data
207
+ # @return [void]
181
208
  def save(data)
182
209
  save_to_file(dump_jwk(data[:account_key]))
183
210
  end
@@ -187,15 +214,18 @@ module LetsCert
187
214
 
188
215
 
189
216
  # OpenSSL IOPlugin
217
+ # @author Sylvain Daubert
190
218
  class OpenSSLIOPlugin < IOPlugin
191
219
 
192
- # @private Regulat expression to discriminate PEM
220
+ # @private Regular expression to discriminate PEM
193
221
  PEM_RE = /
194
222
  ^-----BEGIN ((?:[\x21-\x2c\x2e-\x7e](?:[- ]?[\x21-\x2c\x2e-\x7e])*)?)\s*-----$
195
223
  .*?
196
224
  ^-----END \1-----\s*
197
225
  /x
198
226
 
227
+ # @param [String] name filename
228
+ # @param [:pem,:der] type
199
229
  def initialize(name, type)
200
230
  case type
201
231
  when :pem
@@ -208,10 +238,16 @@ module LetsCert
208
238
  super(name)
209
239
  end
210
240
 
241
+ # Load key from raw +data+
242
+ # @param [String] data
243
+ # @return [OpenSSL::PKey]
211
244
  def load_key(data)
212
245
  OpenSSL::PKey::RSA.new data
213
246
  end
214
247
 
248
+ # Dump key/cert data
249
+ # @param [OpenSSL::PKey] key
250
+ # @return [String]
215
251
  def dump_key(key)
216
252
  case @type
217
253
  when :pem
@@ -222,6 +258,9 @@ module LetsCert
222
258
  end
223
259
  alias :dump_cert :dump_key
224
260
 
261
+ # Load certificate from raw +data+
262
+ # @param [String] data
263
+ # @return [OpenSSL::X509::Certificate]
225
264
  def load_cert(data)
226
265
  OpenSSL::X509::Certificate.new data
227
266
  end
@@ -229,6 +268,9 @@ module LetsCert
229
268
 
230
269
  private
231
270
 
271
+ # Split concatenated PEMs.
272
+ # @param [String] data
273
+ # @yield [String] pem
232
274
  def split_pems(data)
233
275
  m = data.match(PEM_RE)
234
276
  while (m) do
@@ -241,17 +283,23 @@ module LetsCert
241
283
 
242
284
 
243
285
  # Key file plugin
286
+ # @author Sylvain Daubert
244
287
  class KeyFile < OpenSSLIOPlugin
245
288
  include FileIOPluginMixin
246
289
 
290
+ # @return [Hash] always get +true+ for +:key+ key
247
291
  def persisted
248
292
  @persisted ||= { key: true }
249
293
  end
250
294
 
295
+ # @return [Hash]
251
296
  def load_from_content(content)
252
297
  { key: load_key(content) }
253
298
  end
254
299
 
300
+ # Save private key.
301
+ # @param [Hash] data
302
+ # @return [void]
255
303
  def save(data)
256
304
  save_to_file(dump_key(data[:key]))
257
305
  end
@@ -262,13 +310,16 @@ module LetsCert
262
310
 
263
311
 
264
312
  # Chain file plugin
313
+ # @author Sylvain Daubert
265
314
  class ChainFile < OpenSSLIOPlugin
266
315
  include FileIOPluginMixin
267
316
 
317
+ # @return [Hash] always get +true+ for +:chain+ key
268
318
  def persisted
269
319
  @persisted ||= { chain: true }
270
320
  end
271
321
 
322
+ # @return [Hash]
272
323
  def load_from_content(content)
273
324
  chain = []
274
325
  split_pems(content) do |pem|
@@ -277,6 +328,9 @@ module LetsCert
277
328
  { chain: chain }
278
329
  end
279
330
 
331
+ # Save chain.
332
+ # @param [Hash] data
333
+ # @return [void]
280
334
  def save(data)
281
335
  save_to_file(data[:chain].map { |c| dump_cert(c) }.join)
282
336
  end
@@ -286,12 +340,16 @@ module LetsCert
286
340
 
287
341
 
288
342
  # Fullchain file plugin
343
+ # @author Sylvain Daubert
289
344
  class FullChainFile < ChainFile
290
345
 
346
+ # @return [Hash] always get +true+ for +:cert+ and +:chain+ keys
291
347
  def persisted
292
348
  @persisted ||= { cert: true, chain: true }
293
349
  end
294
350
 
351
+ # Load full certificate chain
352
+ # @return [Hash]
295
353
  def load
296
354
  data = super
297
355
  if data[:chain].nil? or data[:chain].empty?
@@ -303,6 +361,9 @@ module LetsCert
303
361
  { account_key: data[:account_key], key: data[:key], cert: cert, chain: chain }
304
362
  end
305
363
 
364
+ # Save fullchain.
365
+ # @param [Hash] data
366
+ # @return [void]
306
367
  def save(data)
307
368
  super(account_key: data[:account_key], key: data[:key], cert: nil,
308
369
  chain: [data[:cert]] + data[:chain])
@@ -313,17 +374,23 @@ module LetsCert
313
374
 
314
375
 
315
376
  # Cert file plugin
377
+ # @author Sylvain Daubert
316
378
  class CertFile < OpenSSLIOPlugin
317
379
  include FileIOPluginMixin
318
380
 
381
+ # @return [Hash] always get +true+ for +:cert+ key
319
382
  def persisted
320
383
  @persisted ||= { cert: true }
321
384
  end
322
385
 
386
+ # @return [Hash]
323
387
  def load_from_content(content)
324
388
  { cert: load_cert(content) }
325
389
  end
326
390
 
391
+ # Save certificate.
392
+ # @param [Hash] data
393
+ # @return [void]
327
394
  def save(data)
328
395
  save_to_file(dump_cert(data[:cert]))
329
396
  end
@@ -0,0 +1,72 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2016 Sylvain Daubert
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ # Namespace for all letcert's classes.
24
+ # @author Sylvain Daubert
25
+ module LetsCert
26
+
27
+ # Mixin module to add loggability to a class.
28
+ # @author Sylvain Daubert
29
+ module Loggable
30
+
31
+ # Hook called when {Loggable} is included in a class or a module.
32
+ # This hook adds methods from {ClassMethods} as class methods to +mod+.
33
+ # @param [Module] mod
34
+ # @return [void]
35
+ def self.included(mod)
36
+ mod.extend(ClassMethods)
37
+ end
38
+
39
+ # Class methods from {Loggable} module to include in target classes.
40
+ # @author Sylvain Daubert
41
+ module ClassMethods
42
+
43
+ # @private hook called a subclass is created.
44
+ # Take care of all subclasses to later properly set @logger class instance variable.
45
+ # @param [Class] subclass
46
+ # @return [void]
47
+ def inherited(subclass)
48
+ @@subclasses ||= []
49
+ @@subclasses << subclass
50
+ end
51
+
52
+ # Set logger
53
+ # @param [Logger] logger
54
+ # @return [void]
55
+ def logger=(logger)
56
+ @logger = logger
57
+ @@subclasses.each do |subclass|
58
+ subclass.instance_variable_set(:@logger, logger)
59
+ end
60
+ end
61
+
62
+ end
63
+
64
+ # Get logger instance
65
+ # @return [Logger]
66
+ def logger
67
+ @logger ||= self.class.instance_variable_get(:@logger)
68
+ end
69
+
70
+ end
71
+
72
+ end
@@ -0,0 +1,24 @@
1
+ module LetsCert
2
+
3
+ module Loggable
4
+
5
+ module ClassMethods
6
+
7
+ # Set logger
8
+ # @param [Logger] logger
9
+ def self.logger=(logger)
10
+ @@logger = logger
11
+ end
12
+
13
+ end
14
+
15
+
16
+ # Get logger instance
17
+ # @return [Logger]
18
+ def logger
19
+ @logger ||= self.class.class_variable_get(:@@logger)
20
+ end
21
+
22
+ end
23
+
24
+ end
@@ -1,17 +1,41 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2016 Sylvain Daubert
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
1
22
  require 'optparse'
2
23
  require 'logger'
3
24
  require 'fileutils'
4
25
 
5
26
  require_relative 'io_plugin'
27
+ require_relative 'certificate'
6
28
 
7
29
  module LetsCert
8
30
 
31
+ # Runner class: analyse and execute CLI commands.
32
+ # @author Sylvain Daubert
9
33
  class Runner
10
34
 
11
35
  # Custom logger formatter
12
36
  class LoggerFormatter < Logger::Formatter
13
37
 
14
- # @private
38
+ # @private log format
15
39
  FORMAT = "[%s] %5s: %s\n"
16
40
 
17
41
  # @param [String] severity
@@ -26,8 +50,11 @@ module LetsCert
26
50
 
27
51
  private
28
52
 
53
+ # @private simple datetime formatter
54
+ # @param [DateTime] time
55
+ # @return [String]
29
56
  def format_datetime(time)
30
- time.strftime("%Y-%d-%d %H:%M:%S")
57
+ time.strftime("%Y-%m-%d %H:%M:%S")
31
58
  end
32
59
 
33
60
  end
@@ -100,13 +127,15 @@ module LetsCert
100
127
  @logger.debug { "options are: #{@options.inspect}" }
101
128
 
102
129
  IOPlugin.logger = @logger
130
+ Certificate.logger = @logger
103
131
 
104
132
  begin
105
133
  if @options[:revoke]
106
- revoke
134
+ Certificate.revoke @options[:files]
107
135
  RETURN_OK
108
136
  elsif @options[:domains].empty?
109
- raise Error, 'At leat one domain must be given with --domain option'
137
+ raise Error, "At leat one domain must be given with --domain option.\n" +
138
+ "Try 'letscert --help' for more information."
110
139
  else
111
140
  # Check all components are covered by plugins
112
141
  persisted = IOPlugin.empty_data
@@ -128,7 +157,7 @@ module LetsCert
128
157
  RETURN_OK
129
158
  else
130
159
  # update/create cert
131
- new_data(data)
160
+ Certificate.get @options, data
132
161
  RETURN_OK_CERT
133
162
  end
134
163
  end
@@ -141,6 +170,9 @@ module LetsCert
141
170
  end
142
171
 
143
172
 
173
+ # Parse line command options
174
+ # @raise [OptionParser::InvalidOption] on unrecognized or malformed option
175
+ # @return [void]
144
176
  def parse_options
145
177
  @opt_parser = OptionParser.new do |opts|
146
178
  opts.banner = "Usage: lestcert [options]"
@@ -245,72 +277,12 @@ module LetsCert
245
277
  @opt_parser.parse!
246
278
  end
247
279
 
248
- def revoke
249
- @logger.info { "load certificates: #{@options[:files].join(', ')}" }
250
- if @options[:files].empty?
251
- raise Error, 'no certificate to revoke. Pass at least one with '+
252
- ' -f option.'
253
- end
254
-
255
- # Temp
256
- @logger.warn "Not yet implemented"
257
- end
258
-
259
280
 
260
281
  private
261
282
 
262
- def get_account_key(data)
263
- if data.nil?
264
- logger.info { 'No account key. Generate a new one...' }
265
- OpenSSL::PKey::RSA.new(@options[:account_key_size])
266
- else
267
- data
268
- end
269
- end
270
-
271
- # Get ACME client.
272
- #
273
- # Client is only created on first call, then it is cached.
274
- def get_acme_client(account_key)
275
- return @client if @client
276
-
277
- key = get_account_key(account_key)
278
-
279
- @logger.debug { "connect to #{@options[:server]}" }
280
- @client = Acme::Client.new(private_key: key, endpoint: @options[:server])
281
-
282
- if @options[:email].nil?
283
- @logger.warn { '--email was not provided. ACME CA will have no way to ' +
284
- 'contact you!' }
285
- end
286
-
287
- begin
288
- @logger.debug { "register with #{@options[:email]}" }
289
- registration = @client.register(contact: "mailto:#{@options[:email]}")
290
- rescue Acme::Client::Error::Malformed => ex
291
- if ex.message != 'Registration key is already in use'
292
- raise
293
- end
294
- else
295
- #if registration.term_of_service_uri
296
- # @logger.debug { "get terms of service" }
297
- # terms = registration.get_terms
298
- # if !terms.nil?
299
- # tos_digest = OpenSSL::Digest::SHA256.digest(terms)
300
- # if tos_digest != @options[:tos_sha256]
301
- # raise Error, 'Terms Of Service mismatch'
302
- # end
303
-
304
- @logger.debug { "agree terms of service" }
305
- registration.agree_terms
306
- # end
307
- #end
308
- end
309
-
310
- @client
311
- end
312
-
313
283
  # Load existing data from disk
284
+ # @param [Array<String>] files
285
+ # @return [Hash]
314
286
  def load_data_from_disk(files)
315
287
  all_data = IOPlugin.empty_data
316
288
 
@@ -335,6 +307,10 @@ module LetsCert
335
307
 
336
308
  # Check if +cert+ exists and is always valid
337
309
  # @todo For now, only check exitence.
310
+ # @param [nil, OpenSSL::X509::Certificate] cert certificate to valid
311
+ # @param [Array<String>] domains list if domains to valid
312
+ # @param [Number] valid_min minimum validity in seconds to ensure
313
+ # @return [Boolean]
338
314
  def valid_existing_cert(cert, domains, valid_min)
339
315
  if cert.nil?
340
316
  @logger.debug { 'no existing cert' }
@@ -359,6 +335,9 @@ module LetsCert
359
335
  end
360
336
 
361
337
  # Check if a renewal is necessary for +cert+
338
+ # @param [OpenSSL::X509::Certificate] cert
339
+ # @param [Number] valid_min minimum validity in seconds to ensure
340
+ # @return [Boolean]
362
341
  def renewal_necessary?(cert, valid_min)
363
342
  now = Time.now.utc
364
343
  diff = (cert.not_after - now).to_i
@@ -368,102 +347,6 @@ module LetsCert
368
347
  diff < valid_min
369
348
  end
370
349
 
371
- # Create/renew key/cert/chain
372
- def new_data(data)
373
- @logger.info {"create key/cert/chain..." }
374
- roots = compute_roots
375
- @logger.debug { "webroots are: #{roots.inspect}" }
376
-
377
- client = get_acme_client(data[:account_key])
378
-
379
- @logger.debug { 'Get authorization for all domains' }
380
- challenges = {}
381
- roots.keys.each do |domain|
382
- authorization = client.authorize(domain: domain)
383
- if authorization
384
- challenges[domain] = authorization.http01
385
- else
386
- challenges[domain] = nil
387
- end
388
- end
389
-
390
- @logger.debug { 'Check all challenges are HTTP-01' }
391
- if challenges.values.any? { |chall| chall.nil? }
392
- raise Error, 'CA did not offer http-01-only challenge. ' +
393
- 'This client is unable to solve any other challenges.'
394
- end
395
-
396
- challenges.each do |domain, challenge|
397
- begin
398
- FileUtils.mkdir_p(File.join(roots[domain], File.dirname(challenge.filename)))
399
- rescue SystemCallError => ex
400
- raise Error, ex.message
401
- end
402
-
403
- path = File.join(roots[domain], challenge.filename)
404
- logger.debug { "Save validation #{challenge.file_content} to #{path}" }
405
- File.write path, challenge.file_content
406
-
407
- challenge.request_verification
408
-
409
- status = 'pending'
410
- while(status == 'pending') do
411
- sleep(1)
412
- status = challenge.verify_status
413
- end
414
-
415
- if status != 'valid'
416
- @logger.warn { "#{domain} was not successfully verified!" }
417
- else
418
- @logger.info { "#{domain} was successfully verified." }
419
- end
420
-
421
- File.unlink path
422
- end
423
-
424
- if @options[:reuse_key] and !data[:key].nil?
425
- @logger.info { 'Reuse existing private key' }
426
- key = data[:key]
427
- else
428
- @logger.info { 'Generate new private key' }
429
- key = OpenSSL::PKey::RSA.generate(@options[:cert_key_size])
430
- end
431
-
432
- csr = Acme::Client::CertificateRequest.new(names: roots.keys, private_key: key)
433
- cert = client.new_certificate(csr)
434
-
435
- IOPlugin.registered.each do |name, plugin|
436
- plugin.save( account_key: client.private_key, key: key, cert: cert.x509,
437
- chain: cert.x509_chain)
438
- end
439
- end
440
-
441
- # Compute webroots
442
- # @return [Hash] whre key are domains and value are their webroot path
443
- def compute_roots
444
- roots = {}
445
- no_roots = []
446
-
447
- @options[:domains].each do |domain|
448
- match = domain.match(/([\w+\.]+):(.*)/)
449
- if match
450
- roots[match[1]] = match[2]
451
- elsif @options[:default_root]
452
- roots[domain] = @options[:default_root]
453
- else
454
- no_roots << domain
455
- end
456
- end
457
-
458
- if !no_roots.empty?
459
- raise Error, 'root for the following domain(s) are not specified: ' +
460
- no_roots.join(', ') + ".\nTry --default_root or use " +
461
- '-d example.com:/var/www/html syntax.'
462
- end
463
-
464
- roots
465
- end
466
-
467
350
  end
468
351
 
469
352
  end
data/lib/letscert.rb CHANGED
@@ -1,8 +1,30 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2016 Sylvain Daubert
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
1
23
  # Namespace for all letcert's classes.
2
24
  module LetsCert
3
25
 
4
26
  # Letscert version number
5
- VERSION = '0.2.1'
27
+ VERSION = '0.2.2'
6
28
 
7
29
 
8
30
  # Base error class
data/tasks/gem.rake CHANGED
@@ -21,8 +21,8 @@ EOF
21
21
  s.files = files
22
22
  s.executables = ['letscert']
23
23
 
24
- s.add_dependency 'acme-client', '~>0.2.4'
25
- s.add_dependency 'yard', '~>0.8.7'
24
+ s.add_dependency 'acme-client', '~>0.3.0'
25
+ s.add_dependency 'yard', '~>0.8'
26
26
 
27
27
  #s.add_development_dependency 'rspec', '~>3.4'
28
28
  end
data/tasks/yard.rake CHANGED
@@ -2,5 +2,5 @@ require 'yard'
2
2
 
3
3
  YARD::Rake::YardocTask.new do |t|
4
4
  t.options = ['--no-private']
5
- t.files = ['lib/**/*.rb']
5
+ t.files = ['lib/**/*.rb', '-', 'LICENSE']
6
6
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: letscert
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sylvain Daubert
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-02-04 00:00:00.000000000 Z
11
+ date: 2016-02-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: acme-client
@@ -16,28 +16,28 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.2.4
19
+ version: 0.3.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.2.4
26
+ version: 0.3.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: yard
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: 0.8.7
33
+ version: '0.8'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: 0.8.7
40
+ version: '0.8'
41
41
  description: |
42
42
  letscert is a simple Let's Encrypt client written in Ruby. It aims at be as clean as
43
43
  simp_le.
@@ -54,11 +54,12 @@ files:
54
54
  - bin/letscert~
55
55
  - lib/letscert.rb
56
56
  - lib/letscert.rb~
57
+ - lib/letscert/certificate.rb
57
58
  - lib/letscert/certificate.rb~
58
59
  - lib/letscert/io_plugin.rb
59
- - lib/letscert/io_plugin.rb~
60
+ - lib/letscert/loggable.rb
61
+ - lib/letscert/loggable.rb~
60
62
  - lib/letscert/runner.rb
61
- - lib/letscert/runner.rb~
62
63
  - tasks/gem.rake
63
64
  - tasks/gem.rake~
64
65
  - tasks/yard.rake
@@ -1,9 +0,0 @@
1
- module LetsCert
2
-
3
- class IOPlugin
4
-
5
- ALLOWED_PLUGINS = %w(account_key.json cert.der cert.pem chain.pem full.pem) +
6
- %w(fullchain.pem key.der key.pem)
7
- end
8
-
9
- end
@@ -1,10 +0,0 @@
1
- module LetsCert
2
-
3
- class Runner
4
-
5
- def self.run
6
- end
7
-
8
- end
9
-
10
- end