letscert 0.2.4 → 0.3.0
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 +4 -4
- data/README.md +27 -12
- data/lib/letscert.rb +1 -1
- data/lib/letscert/certificate.rb +75 -13
- data/lib/letscert/runner.rb +15 -49
- data/spec/certificate_spec.rb +56 -0
- data/spec/certificate_spec.rb~ +8 -0
- data/tasks/gem.rake +4 -2
- metadata +20 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4d86e5665c2e35534ded942c0603cb47b927e05e
|
4
|
+
data.tar.gz: f2d9fa631de0f0aee3ec3e051f6c2a78ff85bdf1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 103bc74e75d1a69f004951cbd681768a3efd11520c59ed38cc389a81aaf750ec097c94d84a57a9898fc298237309bc84f48d27dc5dd18d371d952ba48eab09e1
|
7
|
+
data.tar.gz: 717dfaff074bd6c3809045e56ff51e7518c2c0f2bc2ce5990e8777121b3ea6dda3f67e4b90dec891534dd0f9e4cba60a3f72bd36e2e26e62b18de77ee23b89e3
|
data/README.md
CHANGED
@@ -9,32 +9,47 @@ in Ruby.
|
|
9
9
|
|
10
10
|
# Usage
|
11
11
|
|
12
|
-
Generate a key pair and get signed certificate:
|
12
|
+
## Generate a key pair and get signed certificate:
|
13
|
+
With full chain support (`fullchain.pem` file will contain all certificates):
|
13
14
|
```bash
|
14
|
-
letscert -d example.com:/var/www/example.com/html -f account_key.json -f key.pem -f
|
15
|
+
letscert -d example.com:/var/www/example.com/html --email my.name@domain.tld -f account_key.json -f key.pem -f fullchain.pem
|
15
16
|
```
|
16
|
-
|
17
|
-
|
17
|
+
else (certificate for example.com is in `cert.pem` file, rest of certification chain
|
18
|
+
is in `chain.pem`):
|
18
19
|
```bash
|
19
|
-
letscert -d example.com
|
20
|
+
letscert -d example.com:/var/www/example.com/html --email my.name@domain.tld -f account_key.json -f key.pem -f cert.pem -f chain.pem
|
20
21
|
```
|
21
22
|
|
22
23
|
Commands are the sames for certificate renewal.
|
23
24
|
|
25
|
+
|
26
|
+
## Generate a key pair and get a signed certificate for multi-domains:
|
27
|
+
Generate a single certificate for `example.com` and `www.example.com`:
|
28
|
+
```bash
|
29
|
+
letscert -d example.com -d www.example.com --default-root /var/www/html --email my.name@domain.tld -f account_key.json -f key.pem -f fullchain.pem
|
30
|
+
```
|
31
|
+
|
32
|
+
Command is the same for certificate renewal.
|
33
|
+
|
34
|
+
## Revoke a key pair:
|
35
|
+
From directory where are stored `account_key.json` and `cert.pem` or `fullchain.pem`:
|
36
|
+
```bash
|
37
|
+
letscert -d example.com:/var/www/example.com/html --email my.name@domain.tld --revoke
|
38
|
+
```
|
39
|
+
|
40
|
+
|
24
41
|
# What `letscert` do
|
25
42
|
|
26
|
-
* Automagically a new ACME account if needed.
|
43
|
+
* Automagically create a new ACME account if needed.
|
27
44
|
* Issue new certificate if no previous one found.
|
28
45
|
* Renew certificate only if needed.
|
29
|
-
* Only `http-01` challenge supported. An existing web server must be alreay running.
|
46
|
+
* Only `http-01` challenge supported. An existing web server must be alreay running.
|
47
|
+
`letscert` should have write access to `${webroot}/.well-known/acme-challenge`.
|
30
48
|
* Crontab friendly: no promts.
|
31
49
|
* No configuration file.
|
32
|
-
* Support multiple domains with multiple roots. Always create a single certificate per
|
33
|
-
(ie a certificate may have multiple SANs).
|
50
|
+
* Support multiple domains with multiple roots. Always create a single certificate per
|
51
|
+
run (ie a certificate may have multiple SANs).
|
34
52
|
* As `simp_le`, check the exit code to known if a renewal has happened:
|
35
53
|
* 0 if certificate data was created or updated;
|
36
54
|
* 1 if renewal not necessary;
|
37
55
|
* 2 in case of errors.
|
38
|
-
|
39
|
-
# Todo
|
40
|
-
Add support to revocation.
|
data/lib/letscert.rb
CHANGED
data/lib/letscert/certificate.rb
CHANGED
@@ -24,34 +24,31 @@ require_relative 'loggable'
|
|
24
24
|
module LetsCert
|
25
25
|
|
26
26
|
# Class to handle ACME operations on certificates
|
27
|
+
# @author Sylvain Daubert
|
27
28
|
class Certificate
|
28
29
|
include Loggable
|
29
30
|
|
30
|
-
|
31
|
-
# @param [
|
32
|
-
def
|
33
|
-
|
31
|
+
|
32
|
+
# @param [OpenSSL::X509::Certificate,nil] cert
|
33
|
+
def initialize(cert)
|
34
|
+
@cert = cert
|
34
35
|
end
|
35
36
|
|
36
37
|
# Get a new certificate, or renew an existing one
|
37
|
-
# @param [
|
38
|
+
# @param [OpenSSL::PKey::PKey] account_key private key to authenticate to ACME server
|
39
|
+
# @param [OpenSSL::PKey::PKey] key private key from which make a certificate
|
38
40
|
# @param [Hash] data
|
39
|
-
def
|
40
|
-
new.get options, data
|
41
|
-
end
|
42
|
-
|
43
|
-
def get(options, data)
|
41
|
+
def get(account_key, key, options)
|
44
42
|
logger.info {"create key/cert/chain..." }
|
45
43
|
roots = compute_roots(options)
|
46
44
|
logger.debug { "webroots are: #{roots.inspect}" }
|
47
45
|
|
48
|
-
client = get_acme_client(
|
46
|
+
client = get_acme_client(account_key, options)
|
49
47
|
|
50
48
|
do_challenges client, roots
|
51
49
|
|
52
|
-
if options[:reuse_key] and !
|
50
|
+
if options[:reuse_key] and !key.nil?
|
53
51
|
logger.info { 'Reuse existing private key' }
|
54
|
-
key = data[:key]
|
55
52
|
else
|
56
53
|
logger.info { 'Generate new private key' }
|
57
54
|
key = OpenSSL::PKey::RSA.generate(options[:cert_key_size])
|
@@ -68,6 +65,58 @@ module LetsCert
|
|
68
65
|
end
|
69
66
|
end
|
70
67
|
|
68
|
+
# Revoke certificate
|
69
|
+
# @param [OpenSSL::PKey::PKey] account_key
|
70
|
+
# @return [Boolean]
|
71
|
+
def revoke(account_key, options)
|
72
|
+
if @cert.nil?
|
73
|
+
raise Error, 'no certification data to revoke'
|
74
|
+
end
|
75
|
+
|
76
|
+
client = get_acme_client(account_key, options)
|
77
|
+
begin
|
78
|
+
result = client.revoke_certificate(@cert)
|
79
|
+
rescue Exception => ex
|
80
|
+
raise
|
81
|
+
end
|
82
|
+
|
83
|
+
if result
|
84
|
+
logger.info { 'certificate is revoked' }
|
85
|
+
else
|
86
|
+
logger.warn { 'certificate is not revoked!' }
|
87
|
+
end
|
88
|
+
|
89
|
+
result
|
90
|
+
end
|
91
|
+
|
92
|
+
# Check if certificate is still valid for at least +valid_min+ seconds.
|
93
|
+
# Also checks that +domains+ are certified by certificate.
|
94
|
+
# @param [Array<String>] domains list of certificate domains
|
95
|
+
# @param [Integer] valid_min
|
96
|
+
# @return [Boolean]
|
97
|
+
def valid?(domains, valid_min=0)
|
98
|
+
if @cert.nil?
|
99
|
+
logger.debug { 'no existing certificate' }
|
100
|
+
return false
|
101
|
+
end
|
102
|
+
|
103
|
+
subjects = []
|
104
|
+
@cert.extensions.each do |ext|
|
105
|
+
if ext.oid == 'subjectAltName'
|
106
|
+
subjects += ext.value.split(/,\s*/).map { |s| s.sub(/DNS:/, '') }
|
107
|
+
end
|
108
|
+
end
|
109
|
+
logger.debug { "cert SANs: #{subjects.join(', ')}" }
|
110
|
+
|
111
|
+
# Check all domains are subjects of certificate
|
112
|
+
unless domains.all? { |domain| subjects.include? domain }
|
113
|
+
raise Error, "At least one domain is not declared as a certificate subject." +
|
114
|
+
"Backup and remove existing cert if you want to proceed"
|
115
|
+
end
|
116
|
+
|
117
|
+
!renewal_necessary?(valid_min)
|
118
|
+
end
|
119
|
+
|
71
120
|
|
72
121
|
private
|
73
122
|
|
@@ -206,6 +255,19 @@ module LetsCert
|
|
206
255
|
end
|
207
256
|
end
|
208
257
|
|
258
|
+
# Check if a renewal is necessary for +cert+
|
259
|
+
# @param [OpenSSL::X509::Certificate] cert
|
260
|
+
# @param [Number] valid_min minimum validity in seconds to ensure
|
261
|
+
# @return [Boolean]
|
262
|
+
def renewal_necessary?(valid_min)
|
263
|
+
now = Time.now.utc
|
264
|
+
diff = (@cert.not_after - now).to_i
|
265
|
+
logger.debug { "Certificate expires in #{diff}s on #{@cert.not_after}" +
|
266
|
+
" (relative to #{now})" }
|
267
|
+
|
268
|
+
diff < valid_min
|
269
|
+
end
|
270
|
+
|
209
271
|
end
|
210
272
|
|
211
273
|
end
|
data/lib/letscert/runner.rb
CHANGED
@@ -130,12 +130,19 @@ module LetsCert
|
|
130
130
|
Certificate.logger = @logger
|
131
131
|
|
132
132
|
begin
|
133
|
-
if @options[:
|
134
|
-
Certificate.revoke @options[:files]
|
135
|
-
RETURN_OK
|
136
|
-
elsif @options[:domains].empty?
|
133
|
+
if @options[:domains].empty?
|
137
134
|
raise Error, "At leat one domain must be given with --domain option.\n" +
|
138
135
|
"Try 'letscert --help' for more information."
|
136
|
+
end
|
137
|
+
|
138
|
+
if @options[:revoke]
|
139
|
+
data = load_data_from_disk(IOPlugin.registered.keys)
|
140
|
+
certificate = Certificate.new(data[:cert])
|
141
|
+
if certificate.revoke(data[:account_key], @options)
|
142
|
+
RETURN_OK
|
143
|
+
else
|
144
|
+
RETURN_ERROR
|
145
|
+
end
|
139
146
|
else
|
140
147
|
# Check all components are covered by plugins
|
141
148
|
persisted = IOPlugin.empty_data
|
@@ -152,12 +159,13 @@ module LetsCert
|
|
152
159
|
|
153
160
|
data = load_data_from_disk(@options[:files])
|
154
161
|
|
155
|
-
|
162
|
+
certificate = Certificate.new(data[:cert])
|
163
|
+
if certificate.valid?(@options[:domains], @options[:valid_min])
|
156
164
|
@logger.info { 'no need to update cert' }
|
157
165
|
RETURN_OK
|
158
166
|
else
|
159
167
|
# update/create cert
|
160
|
-
|
168
|
+
certificate.get data[:account_key], data[:key], @options
|
161
169
|
RETURN_OK_CERT
|
162
170
|
end
|
163
171
|
end
|
@@ -197,7 +205,7 @@ module LetsCert
|
|
197
205
|
@options[:domains] << domain
|
198
206
|
end
|
199
207
|
|
200
|
-
opts.on('--
|
208
|
+
opts.on('--default-root PATH', 'Default webroot path',
|
201
209
|
'Use for domains without PATH part.') do |path|
|
202
210
|
@options[:default_root] = path
|
203
211
|
end
|
@@ -305,48 +313,6 @@ module LetsCert
|
|
305
313
|
all_data
|
306
314
|
end
|
307
315
|
|
308
|
-
# Check if +cert+ exists and is always valid
|
309
|
-
# @todo For now, only check exitence.
|
310
|
-
# @param [nil, OpenSSL::X509::Certificate] cert certificate to valid
|
311
|
-
# @param [Array<String>] domains list if domains to valid
|
312
|
-
# @param [Number] valid_min minimum validity in seconds to ensure
|
313
|
-
# @return [Boolean]
|
314
|
-
def valid_existing_cert(cert, domains, valid_min)
|
315
|
-
if cert.nil?
|
316
|
-
@logger.debug { 'no existing cert' }
|
317
|
-
return false
|
318
|
-
end
|
319
|
-
|
320
|
-
subjects = []
|
321
|
-
cert.extensions.each do |ext|
|
322
|
-
if ext.oid == 'subjectAltName'
|
323
|
-
subjects += ext.value.split(/,\s*/).map { |s| s.sub(/DNS:/, '') }
|
324
|
-
end
|
325
|
-
end
|
326
|
-
@logger.debug { "cert SANs: #{subjects.join(', ')}" }
|
327
|
-
|
328
|
-
# Check all domains are subjects of certificate
|
329
|
-
unless domains.all? { |domain| subjects.include? domain }
|
330
|
-
raise Error, "At least one domain is not declared as a certificate subject." +
|
331
|
-
"Backup and remove existing cert if you want to proceed"
|
332
|
-
end
|
333
|
-
|
334
|
-
!renewal_necessary?(cert, valid_min)
|
335
|
-
end
|
336
|
-
|
337
|
-
# Check if a renewal is necessary for +cert+
|
338
|
-
# @param [OpenSSL::X509::Certificate] cert
|
339
|
-
# @param [Number] valid_min minimum validity in seconds to ensure
|
340
|
-
# @return [Boolean]
|
341
|
-
def renewal_necessary?(cert, valid_min)
|
342
|
-
now = Time.now.utc
|
343
|
-
diff = (cert.not_after - now).to_i
|
344
|
-
@logger.debug { "Certificate expires in #{diff}s on #{cert.not_after}" +
|
345
|
-
" (relative to #{now})" }
|
346
|
-
|
347
|
-
diff < valid_min
|
348
|
-
end
|
349
|
-
|
350
316
|
end
|
351
317
|
|
352
318
|
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
module LetsCert
|
4
|
+
|
5
|
+
describe Certificate do
|
6
|
+
|
7
|
+
before(:all) { Certificate.logger = Logger.new('/dev/null') }
|
8
|
+
|
9
|
+
context '#valid?' do
|
10
|
+
|
11
|
+
before(:all) do
|
12
|
+
root_key = OpenSSL::PKey::RSA.new(512)
|
13
|
+
|
14
|
+
@domains = %w(example.org www.example.org)
|
15
|
+
|
16
|
+
key = OpenSSL::PKey::RSA.new(512)
|
17
|
+
@cert = OpenSSL::X509::Certificate.new
|
18
|
+
@cert.version = 2
|
19
|
+
@cert.serial = 2
|
20
|
+
@cert.issuer = OpenSSL::X509::Name.parse "/DC=letscert/CN=CA"
|
21
|
+
@cert.public_key = key.public_key
|
22
|
+
@cert.not_before = Time.now
|
23
|
+
# 20 days validity
|
24
|
+
@cert.not_after = @cert.not_before + 20 * 24 * 60 * 60
|
25
|
+
ef = OpenSSL::X509::ExtensionFactory.new
|
26
|
+
ef.subject_certificate = @cert
|
27
|
+
@domains.each do |domain|
|
28
|
+
@cert.add_extension(ef.create_extension('subjectAltName',
|
29
|
+
"DNS:#{domain}",
|
30
|
+
false))
|
31
|
+
end
|
32
|
+
@cert.sign(root_key, OpenSSL::Digest::SHA256.new)
|
33
|
+
end
|
34
|
+
|
35
|
+
let(:certificate) { Certificate.new(@cert) }
|
36
|
+
|
37
|
+
it 'checks whether a certificate is valid given a minimum valid duration' do
|
38
|
+
expect(certificate.valid?(@domains)).to be(true)
|
39
|
+
expect(certificate.valid?(@domains, 19)).to be(true)
|
40
|
+
expect(certificate.valid?(@domains, 21 * 24 * 3600)).to be(false)
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'raises whether a certificate does not validate a given domain' do
|
44
|
+
expect(certificate.valid?(@domains)).to be(true)
|
45
|
+
expect(certificate.valid?(@domains[0, 1])).to be(true)
|
46
|
+
|
47
|
+
domains = @domains + %w(another.tld)
|
48
|
+
expect { certificate.valid?(domains) }.to raise_error(LetsCert::Error)
|
49
|
+
expect { certificate.valid?(%w(another.tld)) }.to raise_error(LetsCert::Error)
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
data/tasks/gem.rake
CHANGED
@@ -21,11 +21,13 @@ EOF
|
|
21
21
|
s.files = files
|
22
22
|
s.executables = ['letscert']
|
23
23
|
|
24
|
+
s.required_ruby_version = '>= 2.1.0'
|
25
|
+
|
24
26
|
s.add_dependency 'acme-client', '~>0.3.0'
|
25
27
|
s.add_dependency 'json-jwt', '~>1.5'
|
26
|
-
s.add_dependency 'yard', '~>0.8'
|
27
28
|
|
28
|
-
|
29
|
+
s.add_development_dependency 'rspec', '~>3.4'
|
30
|
+
s.add_development_dependency 'yard', '~>0.8'
|
29
31
|
end
|
30
32
|
|
31
33
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: letscert
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sylvain Daubert
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-02-
|
11
|
+
date: 2016-02-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: acme-client
|
@@ -38,6 +38,20 @@ dependencies:
|
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '1.5'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.4'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.4'
|
41
55
|
- !ruby/object:Gem::Dependency
|
42
56
|
name: yard
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -45,7 +59,7 @@ dependencies:
|
|
45
59
|
- - "~>"
|
46
60
|
- !ruby/object:Gem::Version
|
47
61
|
version: '0.8'
|
48
|
-
type: :
|
62
|
+
type: :development
|
49
63
|
prerelease: false
|
50
64
|
version_requirements: !ruby/object:Gem::Requirement
|
51
65
|
requirements:
|
@@ -73,6 +87,8 @@ files:
|
|
73
87
|
- spec/account_key.json
|
74
88
|
- spec/cert.der
|
75
89
|
- spec/cert.pem
|
90
|
+
- spec/certificate_spec.rb
|
91
|
+
- spec/certificate_spec.rb~
|
76
92
|
- spec/chain.pem
|
77
93
|
- spec/fullchain.pem
|
78
94
|
- spec/io_plugin_spec.rb
|
@@ -96,7 +112,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
96
112
|
requirements:
|
97
113
|
- - ">="
|
98
114
|
- !ruby/object:Gem::Version
|
99
|
-
version:
|
115
|
+
version: 2.1.0
|
100
116
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
101
117
|
requirements:
|
102
118
|
- - ">="
|