letscert 0.2.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 93eea77b95f4023766df113b6fcf0af05cac41f7
4
+ data.tar.gz: eefe4a0c05303c3c3b7d6cd554aa518b73d3da43
5
+ SHA512:
6
+ metadata.gz: 308c8ddba083a0622f81e213fcd31133b5b8161b8571ee437a05cf4bd5695cef031f0beec14945361b197822a075727e57264e12a5ecdbf49d3e23862401f74b
7
+ data.tar.gz: b4bded7e572fa89fb693d2f511a002a0e1f523272ffeaa0bb3de441bc15dcad2b4de2cee6ebfad35a441fe7d0bfa7796e0b9166cd4c693192fc30762dfac5355
data/LICENSE ADDED
@@ -0,0 +1,21 @@
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.
data/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # letscert
2
+ A simple `Let's Encrypt` client in ruby.
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.
8
+
9
+ # Usage
10
+
11
+ Generate a key pair and get signed certificate
12
+ ```bash
13
+ letscert -d example.com:/var/www/example.com/html -f key.pem -f cert.pem -f fullchain.pem
14
+ ```
15
+
16
+ The command is the same for certificate renewal.
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ Dir.glob('tasks/*.rake').each do |file|
2
+ load file
3
+ end
data/bin/letscert ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/ruby
2
+
3
+ require 'acme-client'
4
+ require 'letscert'
5
+
6
+ code = LetsCert::Runner.run
7
+
8
+ exit code
data/bin/letscert~ ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/ruby
2
+
3
+ require 'acme-client'
4
+
@@ -0,0 +1,14 @@
1
+ module LetsCert
2
+
3
+ # Class to handle certificates.
4
+ 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
+ end
13
+
14
+ end
@@ -0,0 +1,335 @@
1
+ require 'json'
2
+ require 'base64'
3
+
4
+ module LetsCert
5
+
6
+ # Input/output plugin
7
+ class IOPlugin
8
+
9
+ # Plugin name
10
+ # @return [String]
11
+ attr_reader :name
12
+
13
+ # Allowed plugin names
14
+ ALLOWED_PLUGINS = %w(account_key.json cert.der cert.pem chain.pem full.pem) +
15
+ %w(fullchain.pem key.der key.pem)
16
+
17
+
18
+ @@registered = {}
19
+
20
+ # Get empty data
21
+ def self.empty_data
22
+ { account_key: nil, key: nil, cert: nil, chain: nil }
23
+ end
24
+
25
+ # Register a plugin
26
+ def self.register(klass, *args)
27
+ plugin = klass.new(*args)
28
+ if plugin.name =~ /[\/\\]/ or ['.', '..'].include?(plugin.name)
29
+ raise Error, "plugin name should just ne a file name, without path"
30
+ end
31
+
32
+ @@registered[plugin.name] = plugin
33
+
34
+ klass
35
+ end
36
+
37
+ # Get registered plugins
38
+ def self.registered
39
+ @@registered
40
+ end
41
+
42
+ # Set logger
43
+ def self.logger=(logger)
44
+ @@logger = logger
45
+ end
46
+
47
+ # @param [String] name
48
+ def initialize(name)
49
+ @name = name
50
+ end
51
+
52
+ # Get logger instance
53
+ # @return [Logger]
54
+ def logger
55
+ @logger ||= self.class.class_variable_get(:@@logger)
56
+ end
57
+
58
+ # @abstract This method must be overriden in subclasses
59
+ def load
60
+ raise NotImplementedError
61
+ end
62
+
63
+ # @abstract This method must be overriden in subclasses
64
+ def save
65
+ raise NotImplementedError
66
+ end
67
+
68
+ end
69
+
70
+
71
+ # Mixin for IOPmugin subclasses that handle files
72
+ module FileIOPluginMixin
73
+
74
+ # Load data from file named {#name}
75
+ # @return [Hash]
76
+ def load
77
+ logger.debug { "Loading #@name" }
78
+
79
+ begin
80
+ content = File.read(@name)
81
+ rescue SystemCallError => ex
82
+ if ex.is_a? Errno::ENOENT
83
+ logger.info { "no #@name file" }
84
+ return self.class.empty_data
85
+ end
86
+ raise
87
+ end
88
+
89
+ load_from_content(content)
90
+ end
91
+
92
+ # @abstract
93
+ # @return [Hash]
94
+ def load_from_content(content)
95
+ raise NotImplementedError
96
+ end
97
+
98
+ # Save data to file {#name}
99
+ # @param [Hash] data
100
+ # @return [void]
101
+ def save_to_file(data)
102
+ return if data.nil?
103
+
104
+ logger.info { "saving #@name" }
105
+ begin
106
+ File.open(name, 'w') do |f|
107
+ f.write(data)
108
+ end
109
+ rescue Errno => ex
110
+ @logger.error { ex.message }
111
+ raise Error, "Error when saving #@name"
112
+ end
113
+ end
114
+
115
+ end
116
+
117
+
118
+ # Mixin for IOPlugin subclasses that handle JWK
119
+ module JWKIOPluginMixin
120
+
121
+ # Load crypto data from JSON-encoded file
122
+ # @param [String] data JSON-encoded data
123
+ # @return [Hash]
124
+ def load_jwk(data)
125
+ return nil if data.empty?
126
+
127
+ hsh = JSON.parse(data)
128
+
129
+ key = OpenSSL::PKey::RSA.new
130
+ key.n = OpenSSL::BN.new(Base64.strict_decode64(hsh['n']))
131
+ key.e = OpenSSL::BN.new(Base64.strict_decode64(hsh['e']))
132
+ key.d = OpenSSL::BN.new(Base64.strict_decode64(hsh['e']))
133
+ key.p = OpenSSL::BN.new(Base64.strict_decode64(hsh['p']))
134
+ key.q = OpenSSL::BN.new(Base64.strict_decode64(hsh['q']))
135
+ key.dmp1 = OpenSSL::BN.new(Base64.strict_decode64(hsh['dp']))
136
+ key.dmq1 = OpenSSL::BN.new(Base64.strict_decode64(hsh['dq']))
137
+ key.iqmp = OpenSSL::BN.new(Base64.strict_decode64(hsh['qi']))
138
+
139
+ key
140
+ end
141
+
142
+ # Dump crypto data (key) to a JSON-encoded string
143
+ # @param [OpenSSL::PKey] jwk
144
+ # @return [String]
145
+ def dump_jwk(jwk)
146
+ hsh = jwk.params
147
+
148
+ # Add and rename some fields to be compatible with simp_le
149
+ hsh['kty'] = 'RSA'
150
+ hsh['qi'] = hsh['iqmp'].dup
151
+ hsh['dp'] = hsh['dmp1'].dup
152
+ hsh['dq'] = hsh['dmq1'].dup
153
+ hsh.delete('iqmp')
154
+ hsh.delete('dmpl')
155
+ hsh.delete('dmql')
156
+ hsh.rehash
157
+
158
+ hsh.each_key do |key|
159
+ if hsh[key].is_a?(OpenSSL::BN)
160
+ hsh[key] = Base64.strict_encode64(hsh[key].to_s)
161
+ end
162
+ end
163
+ hsh.to_json
164
+ end
165
+ end
166
+
167
+
168
+ # Account key IO plugin
169
+ class AccountKey < IOPlugin
170
+ include FileIOPluginMixin
171
+ include JWKIOPluginMixin
172
+
173
+ def persisted
174
+ { account_key: true }
175
+ end
176
+
177
+ def load_from_content(content)
178
+ { account_key: load_jwk(content) }
179
+ end
180
+
181
+ def save(data)
182
+ save_to_file(dump_jwk(data[:account_key]))
183
+ end
184
+
185
+ end
186
+ IOPlugin.register(AccountKey, 'account_key.json')
187
+
188
+
189
+ # OpenSSL IOPlugin
190
+ class OpenSSLIOPlugin < IOPlugin
191
+
192
+ # @private Regulat expression to discriminate PEM
193
+ PEM_RE = /
194
+ ^-----BEGIN ((?:[\x21-\x2c\x2e-\x7e](?:[- ]?[\x21-\x2c\x2e-\x7e])*)?)\s*-----$
195
+ .*?
196
+ ^-----END \1-----\s*
197
+ /x
198
+
199
+ def initialize(name, type)
200
+ case type
201
+ when :pem
202
+ when :der
203
+ else
204
+ raise ArgumentError, "type should be :pem or :der"
205
+ end
206
+
207
+ @type = type
208
+ super(name)
209
+ end
210
+
211
+ def load_key(data)
212
+ OpenSSL::PKey::RSA.new data
213
+ end
214
+
215
+ def dump_key(key)
216
+ case @type
217
+ when :pem
218
+ key.to_pem
219
+ when :der
220
+ key.to_der
221
+ end
222
+ end
223
+ alias :dump_cert :dump_key
224
+
225
+ def load_cert(data)
226
+ OpenSSL::X509::Certificate.new data
227
+ end
228
+
229
+
230
+ private
231
+
232
+ def split_pems(data)
233
+ m = data.match(PEM_RE)
234
+ while (m) do
235
+ yield m[0]
236
+ m = [data[m.end(0)..-1]].match(PEM_RE)
237
+ end
238
+ end
239
+
240
+ end
241
+
242
+
243
+ # Key file plugin
244
+ class KeyFile < OpenSSLIOPlugin
245
+ include FileIOPluginMixin
246
+
247
+ def persisted
248
+ @persisted ||= { key: true }
249
+ end
250
+
251
+ def load_from_content(content)
252
+ { key: load_key(content) }
253
+ end
254
+
255
+ def save(data)
256
+ save_to_file(dump_key(data[:key]))
257
+ end
258
+
259
+ end
260
+ IOPlugin.register(KeyFile, 'key.pem', :pem)
261
+ IOPlugin.register(KeyFile, 'key.der', :der)
262
+
263
+
264
+ # Chain file plugin
265
+ class ChainFile < OpenSSLIOPlugin
266
+ include FileIOPluginMixin
267
+
268
+ def persisted
269
+ @persisted ||= { chain: true }
270
+ end
271
+
272
+ def load_from_content(content)
273
+ chain = []
274
+ split_pems(content) do |pem|
275
+ chain << load_cert(pem)
276
+ end
277
+ { chain: chain }
278
+ end
279
+
280
+ def save(data)
281
+ save_to_file(data[:chain].map { |c| dump_cert(c) }.join)
282
+ end
283
+
284
+ end
285
+ IOPlugin.register(ChainFile, 'chain.pem', :pem)
286
+
287
+
288
+ # Fullchain file plugin
289
+ class FullChainFile < ChainFile
290
+
291
+ def persisted
292
+ @persisted ||= { cert: true, chain: true }
293
+ end
294
+
295
+ def load
296
+ data = super
297
+ if data[:chain].nil? or data[:chain].empty?
298
+ cert, chain = nil, nil
299
+ else
300
+ cert, chain = data[:chain]
301
+ end
302
+
303
+ { account_key: data[:account_key], key: data[:key], cert: cert, chain: chain }
304
+ end
305
+
306
+ def save(data)
307
+ super(account_key: data[:account_key], key: data[:key], cert: nil,
308
+ chain: [data[:cert]] + data[:chain])
309
+ end
310
+
311
+ end
312
+ IOPlugin.register(FullChainFile, 'fullchain.pem', :pem)
313
+
314
+
315
+ # Cert file plugin
316
+ class CertFile < OpenSSLIOPlugin
317
+ include FileIOPluginMixin
318
+
319
+ def persisted
320
+ @persisted ||= { cert: true }
321
+ end
322
+
323
+ def load_from_content(content)
324
+ { cert: load_cert(content) }
325
+ end
326
+
327
+ def save(data)
328
+ save_to_file(dump_cert(data[:cert]))
329
+ end
330
+
331
+ end
332
+ IOPlugin.register(CertFile, 'cert.pem', :pem)
333
+ IOPlugin.register(CertFile, 'cert.der', :der)
334
+
335
+ end
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,469 @@
1
+ require 'optparse'
2
+ require 'logger'
3
+ require 'fileutils'
4
+
5
+ require_relative 'io_plugin'
6
+
7
+ module LetsCert
8
+
9
+ class Runner
10
+
11
+ # Custom logger formatter
12
+ class LoggerFormatter < Logger::Formatter
13
+
14
+ # @private
15
+ FORMAT = "[%s] %5s: %s\n"
16
+
17
+ # @param [String] severity
18
+ # @param [Datetime] time
19
+ # @param [nil,String] progname
20
+ # @param [String] msg
21
+ # @return [String]
22
+ def call(severity, time, progname, msg)
23
+ FORMAT % [format_datetime(time), severity, msg2str(msg)]
24
+ end
25
+
26
+
27
+ private
28
+
29
+ def format_datetime(time)
30
+ time.strftime("%Y-%d-%d %H:%M:%S")
31
+ end
32
+
33
+ end
34
+
35
+
36
+ # Exit value for OK
37
+ RETURN_OK = 1
38
+ # Exit value for OK but with creation/renewal of certificate data
39
+ RETURN_OK_CERT = 0
40
+ # Exit value for error(s)
41
+ RETURN_ERROR = 2
42
+
43
+ # @return [Logger]
44
+ attr_reader :logger
45
+
46
+ # Run LetsCert
47
+ # @return [Integer]
48
+ # @see #run
49
+ def self.run
50
+ runner = new
51
+ runner.parse_options
52
+ runner.run
53
+ end
54
+
55
+
56
+ def initialize
57
+ @options = {
58
+ verbose: 0,
59
+ domains: [],
60
+ files: [],
61
+ cert_key_size: 2048,
62
+ valid_min: 30 * 24 * 60 * 60,
63
+ account_key_public_exponent: 65537,
64
+ account_key_size: 4096,
65
+ tos_sha256: '33d233c8ab558ba6c8ebc370a509acdded8b80e5d587aa5d192193f35226540f',
66
+ user_agent: 'letscert/0',
67
+ server: 'https://acme-v01.api.letsencrypt.org/directory',
68
+ }
69
+
70
+ @logger = Logger.new(STDOUT)
71
+ @logger.formatter = LoggerFormatter.new
72
+ end
73
+
74
+ # @return [Integer] exit code
75
+ # * 0 if certificate data were created or updated
76
+ # * 1 if renewal was not necessery
77
+ # * 2 in case of errors
78
+ def run
79
+ if @options[:print_help]
80
+ puts @opt_parser
81
+ exit RETURN_OK
82
+ end
83
+
84
+ if @options[:show_version]
85
+ puts "letscert #{LetsCert::VERSION}"
86
+ puts "Copyright (c) 2016 Sylvain Daubert"
87
+ puts "License MIT: see http://opensource.org/licenses/MIT"
88
+ exit RETURN_OK
89
+ end
90
+
91
+ case @options[:verbose]
92
+ when 0
93
+ @logger.level = Logger::Severity::WARN
94
+ when 1
95
+ @logger.level = Logger::Severity::INFO
96
+ when 2..5
97
+ @logger.level = Logger::Severity::DEBUG
98
+ end
99
+
100
+ @logger.debug { "options are: #{@options.inspect}" }
101
+
102
+ IOPlugin.logger = @logger
103
+
104
+ begin
105
+ if @options[:revoke]
106
+ revoke
107
+ RETURN_OK
108
+ elsif @options[:domains].empty?
109
+ raise Error, 'At leat one domain must be given with --domain option'
110
+ else
111
+ # Check all components are covered by plugins
112
+ persisted = IOPlugin.empty_data
113
+ @options[:files].each do |file|
114
+ persisted.merge!(IOPlugin.registered[file].persisted) do |k, oldv, newv|
115
+ oldv || newv
116
+ end
117
+ end
118
+ not_persisted = persisted.keys.find_all { |k| !persisted[k] }
119
+ unless not_persisted.empty?
120
+ raise Error, 'Selected IO plugins do not cover following components: ' +
121
+ not_persisted.join(', ')
122
+ end
123
+
124
+ data = load_data_from_disk(@options[:files])
125
+
126
+ if valid_existing_cert(data[:cert], @options[:domains], @options[:valid_min])
127
+ @logger.info { 'no need to update cert' }
128
+ RETURN_OK
129
+ else
130
+ # update/create cert
131
+ new_data(data)
132
+ RETURN_OK_CERT
133
+ end
134
+ end
135
+
136
+ rescue Error, Acme::Client::Error => ex
137
+ @logger.error ex.message
138
+ puts "Error: #{ex.message}"
139
+ RETURN_ERROR
140
+ end
141
+ end
142
+
143
+
144
+ def parse_options
145
+ @opt_parser = OptionParser.new do |opts|
146
+ opts.banner = "Usage: lestcert [options]"
147
+
148
+ opts.separator('')
149
+
150
+ opts.on('-h', '--help', 'Show this help message and exit') do
151
+ @options[:print_help] = true
152
+ end
153
+ opts.on('-V', '--version', 'Show version and exit') do |v|
154
+ @options[:show_version] = v
155
+ end
156
+ opts.on('-v', '--verbose', 'Run verbosely') { |v| @options[:verbose] += 1 if v }
157
+
158
+
159
+ opts.separator("\nWebroot manager:")
160
+
161
+ opts.on('-d', '--domain DOMAIN[:PATH]',
162
+ 'Domain name to include in the certificate.',
163
+ 'Must be specified at least once.',
164
+ 'Its path on the disk must also be provided.') do |domain|
165
+ @options[:domains] << domain
166
+ end
167
+
168
+ opts.on('--default_root PATH', 'Default webroot path',
169
+ 'Use for domains without PATH part.') do |path|
170
+ @options[:default_root] = path
171
+ end
172
+
173
+ opts.separator("\nCertificate data files:")
174
+
175
+ opts.on('--revoke', 'Revoke existing certificates') do |revoke|
176
+ @options[:revoke] = revoke
177
+ end
178
+
179
+ opts.on("-f", "--file FILE", 'Input/output file.',
180
+ 'Can be specified multiple times',
181
+ 'Allowed values: account_key.json, cert.der,',
182
+ 'cert.pem, chain.pem, full.pem,',
183
+ 'fullchain.pem, key.der, key.pem.') do |file|
184
+ @options[:files] << file
185
+ end
186
+
187
+ opts.on('--cert-key-size BITS', Integer,
188
+ 'Certificate key size in bits',
189
+ '(default: 2048)') do |bits|
190
+ @options[:cert_key_size] = bits
191
+ end
192
+
193
+ opts.on('--valid-min SECONDS', Integer, 'Renew existing certificate if validity',
194
+ 'is lesser than SECONDS (default: 2592000 (30 days))') do |time|
195
+ @options[:valid_min] = time
196
+ end
197
+
198
+ opts.on('--reuse-key', 'Reuse previous private key') do |rk|
199
+ @options[:reuse_key] = rk
200
+ end
201
+
202
+ opts.separator("\nRegistration:")
203
+ opts.separator(" Automatically register an account with he ACME CA specified" +
204
+ " by --server")
205
+ opts.separator('')
206
+
207
+ opts.on('--account-key-public-exponent BITS', Integer,
208
+ 'Account key public exponent value (default: 65537)') do |bits|
209
+ @options[:account_key_public_exponent] = bits
210
+ end
211
+
212
+ opts.on('--account-key-size BITS', Integer,
213
+ 'Account key size (default: 4096)') do |bits|
214
+ @options[:account_key_size] = bits
215
+ end
216
+
217
+ opts.on('--tos-sha256 HASH', String,
218
+ 'SHA-256 digest of the content of Terms Of Service URI') do |hash|
219
+ @options[:tos_sha256] = hash
220
+ end
221
+
222
+ opts.on('--email EMAIL', String,
223
+ 'E-mail address. CA is likely to use it to',
224
+ 'remind about expiring certificates, as well',
225
+ 'as for account recovery. It is highly',
226
+ 'recommended to set this value.') do |email|
227
+ @options[:email] = email
228
+ end
229
+
230
+ opts.separator("\nHTTP:")
231
+ opts.separator(' Configure properties of HTTP requests and responses.')
232
+ opts.separator('')
233
+
234
+ opts.on('--user-agent NAME', 'User-Agent sent in all HTTP requests',
235
+ '(default: letscert/0)') do |ua|
236
+ @options[:user_agent] = ua
237
+ end
238
+
239
+ opts.on('--server URI', 'URI for the CA ACME API endpoint',
240
+ '(default: https://acme-v01.api.letsencrypt.org/directory)') do |uri|
241
+ @options[:server] = uri
242
+ end
243
+ end
244
+
245
+ @opt_parser.parse!
246
+ end
247
+
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
+
260
+ private
261
+
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
+ # Load existing data from disk
314
+ def load_data_from_disk(files)
315
+ all_data = IOPlugin.empty_data
316
+
317
+ files.each do |plugin_name|
318
+ persisted = IOPlugin.registered[plugin_name].persisted
319
+ data = IOPlugin.registered[plugin_name].load
320
+
321
+ test = IOPlugin.empty_data.keys.all? do |key|
322
+ persisted[key] or data[key].nil?
323
+ end
324
+ raise Error unless test
325
+
326
+ # Merge data into all_data. New value replace old one only if old one was
327
+ # not defined
328
+ all_data.merge!(data) do |key, oldval, newval|
329
+ oldval || newval
330
+ end
331
+ end
332
+
333
+ all_data
334
+ end
335
+
336
+ # Check if +cert+ exists and is always valid
337
+ # @todo For now, only check exitence.
338
+ def valid_existing_cert(cert, domains, valid_min)
339
+ if cert.nil?
340
+ @logger.debug { 'no existing cert' }
341
+ return false
342
+ end
343
+
344
+ subjects = []
345
+ cert.extensions.each do |ext|
346
+ if ext.oid == 'subjectAltName'
347
+ subjects += ext.value.split(/,\s*/).map { |s| s.sub(/DNS:/, '') }
348
+ end
349
+ end
350
+ @logger.debug { "cert SANs: #{subjects.join(', ')}" }
351
+
352
+ # Check all domains are subjects of certificate
353
+ unless domains.all? { |domain| subjects.include? domain }
354
+ raise Error, "At least one domain is not declared as a certificate subject." +
355
+ "Backup and remove existing cert if you want to proceed"
356
+ end
357
+
358
+ !renewal_necessary?(cert, valid_min)
359
+ end
360
+
361
+ # Check if a renewal is necessary for +cert+
362
+ def renewal_necessary?(cert, valid_min)
363
+ now = Time.now.utc
364
+ diff = (cert.not_after - now).to_i
365
+ @logger.debug { "Certificate expires in #{diff}s on #{cert.not_after}" +
366
+ " (relative to #{now})" }
367
+
368
+ diff < valid_min
369
+ end
370
+
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
+ end
468
+
469
+ end
@@ -0,0 +1,10 @@
1
+ module LetsCert
2
+
3
+ class Runner
4
+
5
+ def self.run
6
+ end
7
+
8
+ end
9
+
10
+ end
data/lib/letscert.rb ADDED
@@ -0,0 +1,13 @@
1
+ # Namespace for all letcert's classes.
2
+ module LetsCert
3
+
4
+ # Letscert version number
5
+ VERSION = '0.2.1'
6
+
7
+
8
+ # Base error class
9
+ class Error < StandardError; end
10
+
11
+ end
12
+
13
+ require_relative 'letscert/runner'
data/lib/letscert.rb~ ADDED
@@ -0,0 +1,6 @@
1
+ module LetsCert
2
+
3
+ # Letscert version number
4
+ VERSION = '0.0.1'
5
+
6
+ end
data/tasks/gem.rake ADDED
@@ -0,0 +1,34 @@
1
+ require 'rubygems/package_task'
2
+ require_relative '../lib/letscert.rb'
3
+
4
+ spec = Gem::Specification.new do |s|
5
+ s.name = 'letscert'
6
+ s.version = LetsCert::VERSION
7
+ s.license = 'MIT'
8
+ s.summary = "letscert, a simple Let's Encrypt client"
9
+ s.description = <<-EOF
10
+ letscert is a simple Let's Encrypt client written in Ruby. It aims at be as clean as
11
+ simp_le.
12
+ EOF
13
+
14
+ s.authors << 'Sylvain Daubert'
15
+ s.email = 'sylvain.daubert@laposte.net'
16
+ s.homepage = 'https://github.com/sdaubert/letscert'
17
+
18
+ files = Dir['{spec,lib,bin,tasks}/**/*']
19
+ files += ['README.md', 'LICENSE', 'Rakefile']
20
+ # For now, device is not in gem.
21
+ s.files = files
22
+ s.executables = ['letscert']
23
+
24
+ s.add_dependency 'acme-client', '~>0.2.4'
25
+ s.add_dependency 'yard', '~>0.8.7'
26
+
27
+ #s.add_development_dependency 'rspec', '~>3.4'
28
+ end
29
+
30
+
31
+ Gem::PackageTask.new(spec) do |pkg|
32
+ pkg.need_zip = true
33
+ pkg.need_tar = true
34
+ end
data/tasks/gem.rake~ ADDED
@@ -0,0 +1,34 @@
1
+ require 'rubygems/package_task'
2
+ require_relative '../lib/letscert.rb'
3
+
4
+ spec = Gem::Specification.new do |s|
5
+ s.name = 'letscert'
6
+ s.version = LetsCert::VERSION
7
+ s.summary = "letscert, a simple Let's Encrypt client"
8
+ s.description = <<-EOF
9
+ letscert is a simple Let's Encrypt client written in Ruby. It aims at be as clean as
10
+ simp_le.
11
+ EOF
12
+
13
+ s.authors << 'Sylvain Daubert'
14
+ s.email = 'sylvain.daubert@laposte.net'
15
+ s.homepage = 'https://github.com/sdaubert/letscert'
16
+
17
+ files = Dir['{spec,lib,bin,tasks}/**/*']
18
+ files += ['README.md', 'MIT-LICENSE', 'Rakefile']
19
+ # For now, device is not in gem.
20
+ s.files = files
21
+ s.executables = ['letscert']
22
+
23
+ s.add_dependency 'acme-client', '~>0.2.4'
24
+ s.add_dependency 'yard', '~>0.8.7'
25
+
26
+ #s.add_development_dependency 'rspec', '~>2.14.0'
27
+ end
28
+
29
+
30
+ Gem::PackageTask.new(spec) do |pkg|
31
+ pkg.need_zip = true
32
+ pkg.need_tar = true
33
+ end
34
+ ~
data/tasks/yard.rake ADDED
@@ -0,0 +1,6 @@
1
+ require 'yard'
2
+
3
+ YARD::Rake::YardocTask.new do |t|
4
+ t.options = ['--no-private']
5
+ t.files = ['lib/**/*.rb']
6
+ end
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: letscert
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Sylvain Daubert
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-02-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: acme-client
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.2.4
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.2.4
27
+ - !ruby/object:Gem::Dependency
28
+ name: yard
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.8.7
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.8.7
41
+ description: |
42
+ letscert is a simple Let's Encrypt client written in Ruby. It aims at be as clean as
43
+ simp_le.
44
+ email: sylvain.daubert@laposte.net
45
+ executables:
46
+ - letscert
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - LICENSE
51
+ - README.md
52
+ - Rakefile
53
+ - bin/letscert
54
+ - bin/letscert~
55
+ - lib/letscert.rb
56
+ - lib/letscert.rb~
57
+ - lib/letscert/certificate.rb~
58
+ - lib/letscert/io_plugin.rb
59
+ - lib/letscert/io_plugin.rb~
60
+ - lib/letscert/runner.rb
61
+ - lib/letscert/runner.rb~
62
+ - tasks/gem.rake
63
+ - tasks/gem.rake~
64
+ - tasks/yard.rake
65
+ homepage: https://github.com/sdaubert/letscert
66
+ licenses:
67
+ - MIT
68
+ metadata: {}
69
+ post_install_message:
70
+ rdoc_options: []
71
+ require_paths:
72
+ - lib
73
+ required_ruby_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ required_rubygems_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ requirements: []
84
+ rubyforge_project:
85
+ rubygems_version: 2.2.2
86
+ signing_key:
87
+ specification_version: 4
88
+ summary: letscert, a simple Let's Encrypt client
89
+ test_files: []