letscert 0.2.1

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