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 +7 -0
- data/LICENSE +21 -0
- data/README.md +16 -0
- data/Rakefile +3 -0
- data/bin/letscert +8 -0
- data/bin/letscert~ +4 -0
- data/lib/letscert/certificate.rb~ +14 -0
- data/lib/letscert/io_plugin.rb +335 -0
- data/lib/letscert/io_plugin.rb~ +9 -0
- data/lib/letscert/runner.rb +469 -0
- data/lib/letscert/runner.rb~ +10 -0
- data/lib/letscert.rb +13 -0
- data/lib/letscert.rb~ +6 -0
- data/tasks/gem.rake +34 -0
- data/tasks/gem.rake~ +34 -0
- data/tasks/yard.rake +6 -0
- metadata +89 -0
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
data/bin/letscert
ADDED
data/bin/letscert~
ADDED
@@ -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,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
|
data/lib/letscert.rb
ADDED
data/lib/letscert.rb~
ADDED
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
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: []
|