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