letscert 0.2.1 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
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