lester 1.0.0.pre1
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/README.md +63 -0
- data/bin/lester +8 -0
- data/lib/lester.rb +22 -0
- data/lib/lester/authenticator.rb +42 -0
- data/lib/lester/cli.rb +101 -0
- data/lib/lester/command.rb +7 -0
- data/lib/lester/command/init.rb +18 -0
- data/lib/lester/command/renew.rb +63 -0
- data/lib/lester/factory.rb +70 -0
- data/lib/lester/private_key.rb +18 -0
- data/lib/lester/s3_store.rb +42 -0
- data/lib/lester/uploader.rb +28 -0
- data/lib/lester/version.rb +3 -0
- data/spec/acceptance/cli_init_spec.rb +47 -0
- data/spec/acceptance/cli_renew_spec.rb +149 -0
- data/spec/lester/authenticator_spec.rb +80 -0
- data/spec/lester/cli_spec.rb +102 -0
- data/spec/lester/command/init_spec.rb +31 -0
- data/spec/lester/command/renew_spec.rb +123 -0
- data/spec/lester/private_key_spec.rb +41 -0
- data/spec/lester/s3_store_spec.rb +73 -0
- data/spec/lester/uploader_spec.rb +116 -0
- data/spec/spec_helper.rb +33 -0
- data/spec/support/acceptance_setup.rb +38 -0
- data/spec/support/cassettes/new-certificate-fail.yml +64 -0
- data/spec/support/cassettes/new-certificate.yml +432 -0
- data/spec/support/cassettes/verification-fail.yml +139 -0
- data/spec/support/fake_bucket.rb +54 -0
- data/spec/support/fake_cloudfront.rb +26 -0
- data/spec/support/fake_iam.rb +16 -0
- data/spec/support/parameter_validation.rb +60 -0
- data/spec/support/resources/privkey.json +1 -0
- data/spec/support/resources/privkey.pem +27 -0
- metadata +182 -0
@@ -0,0 +1,28 @@
|
|
1
|
+
module Lester
|
2
|
+
class Uploader
|
3
|
+
def initialize(iam, cloudfront, distribution_id)
|
4
|
+
@iam = iam
|
5
|
+
@cloudfront = cloudfront
|
6
|
+
@distribution_id = distribution_id
|
7
|
+
end
|
8
|
+
|
9
|
+
def upload(name, certificate, private_key)
|
10
|
+
metadata = @iam.upload_server_certificate({
|
11
|
+
path: '/cloudfront/',
|
12
|
+
server_certificate_name: name,
|
13
|
+
private_key: private_key.to_pem,
|
14
|
+
certificate_body: certificate.to_pem,
|
15
|
+
certificate_chain: certificate.chain_to_pem,
|
16
|
+
}).server_certificate_metadata
|
17
|
+
certificate_id = metadata.server_certificate_id
|
18
|
+
response = @cloudfront.get_distribution_config(id: @distribution_id)
|
19
|
+
distribution_config = response.distribution_config.to_hash
|
20
|
+
distribution_config[:viewer_certificate][:iam_certificate_id] = certificate_id
|
21
|
+
@cloudfront.update_distribution({
|
22
|
+
distribution_config: distribution_config,
|
23
|
+
id: @distribution_id,
|
24
|
+
if_match: response.etag,
|
25
|
+
})
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'bin/lester init' do
|
4
|
+
include_context 'acceptance setup'
|
5
|
+
|
6
|
+
let :command do
|
7
|
+
Lester::Cli.new(argv, io)
|
8
|
+
end
|
9
|
+
|
10
|
+
let :argv do
|
11
|
+
[
|
12
|
+
'init',
|
13
|
+
'--domain', 'example.org',
|
14
|
+
'--storage-bucket', 'example-org-backup',
|
15
|
+
'--private-key', private_key_path,
|
16
|
+
]
|
17
|
+
end
|
18
|
+
|
19
|
+
context 'when the private key exists' do
|
20
|
+
it 'stores it' do
|
21
|
+
command.run
|
22
|
+
object = storage_bucket.object('example.org/account/private_key.json')
|
23
|
+
expect { JSON::JWK.new(JSON.parse(object.read)).to_key }.to_not raise_error
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'returns an ok exit code' do
|
27
|
+
code = command.run
|
28
|
+
expect(code).to eq(0)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
context 'when the private key does not exist' do
|
33
|
+
let :private_key_path do
|
34
|
+
'this/should/not/exist.pem'
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'prints an error message' do
|
38
|
+
command.run
|
39
|
+
expect(io.string).to match(/No such file or directory/)
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'returns a non-ok exit code' do
|
43
|
+
code = command.run
|
44
|
+
expect(code).to eq(1)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'bin/lester renew' do
|
4
|
+
include_context 'acceptance setup'
|
5
|
+
|
6
|
+
let :command do
|
7
|
+
Lester::Cli.new(argv, io)
|
8
|
+
end
|
9
|
+
|
10
|
+
let :argv do
|
11
|
+
[
|
12
|
+
'renew',
|
13
|
+
'--domain', 'example.org',
|
14
|
+
'--endpoint', 'http://127.0.0.1:4000',
|
15
|
+
'--site-bucket', 'example-org-site',
|
16
|
+
'--storage-bucket', 'example-org-backup',
|
17
|
+
'--email', 'contact@example.org',
|
18
|
+
'--distribution-id', 'distribution-id',
|
19
|
+
]
|
20
|
+
end
|
21
|
+
|
22
|
+
before do
|
23
|
+
storage_bucket.put_object(key: 'example.org/account/private_key.json', body: Pathname.new(private_key_path))
|
24
|
+
cloudfront.add_config('distribution-id', {
|
25
|
+
viewer_certificate: { iam_certificate_id: 'example.org-old' },
|
26
|
+
})
|
27
|
+
end
|
28
|
+
|
29
|
+
describe '#run' do
|
30
|
+
context 'with a registered private key', vcr: { cassette_name: 'new-certificate' } do
|
31
|
+
it 'writes challenges to the expected path' do
|
32
|
+
command.run
|
33
|
+
key = '.well-known/acme-challenge/EGJjhjKuz9x5yNbH8IXfcZO6OljhIrwPQeTeZUcsKao'
|
34
|
+
object = site_bucket.object(key)
|
35
|
+
expect(object.read).to eq('EGJjhjKuz9x5yNbH8IXfcZO6OljhIrwPQeTeZUcsKao.XUQVUxLBe1x_yLe3HwXhatO3NImdF032T7C7BjKu6pc')
|
36
|
+
key = '.well-known/acme-challenge/wdo3QEPA0D2mUav-Yx34nbNkq61bLOMjj_3obOYvE2E'
|
37
|
+
object = site_bucket.object(key)
|
38
|
+
expect(object.read).to eq('wdo3QEPA0D2mUav-Yx34nbNkq61bLOMjj_3obOYvE2E.XUQVUxLBe1x_yLe3HwXhatO3NImdF032T7C7BjKu6pc')
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'uploads the new certificate' do
|
42
|
+
command.run
|
43
|
+
expect(iam.certificates).to_not be_empty
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'installs the certificate' do
|
47
|
+
command.run
|
48
|
+
update = cloudfront.updates.first
|
49
|
+
expect(update[:distribution_config][:viewer_certificate][:iam_certificate_id]).to eq('2ae9ea04d305762117cf854b39bb5ede')
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'stores the certificate' do
|
53
|
+
command.run
|
54
|
+
object = storage_bucket.object('example.org/certificates/201512120949/cert.pem')
|
55
|
+
expect { OpenSSL::X509::Certificate.new(object.read) }.to_not raise_error
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'stores the certificate request' do
|
59
|
+
command.run
|
60
|
+
object = storage_bucket.object('example.org/certificates/201512120949/csr.pem')
|
61
|
+
expect { OpenSSL::X509::Request.new(object.read) }.to_not raise_error
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'stores the certificate chain' do
|
65
|
+
command.run
|
66
|
+
object = storage_bucket.object('example.org/certificates/201512120949/chain.pem')
|
67
|
+
expect { OpenSSL::X509::Certificate.new(object.read) }.to_not raise_error
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'stores the certificate fullchain' do
|
71
|
+
command.run
|
72
|
+
object = storage_bucket.object('example.org/certificates/201512120949/fullchain.pem')
|
73
|
+
expect { OpenSSL::X509::Certificate.new(object.read) }.to_not raise_error
|
74
|
+
end
|
75
|
+
|
76
|
+
it 'stores the certificate private key' do
|
77
|
+
command.run
|
78
|
+
object = storage_bucket.object('example.org/certificates/201512120949/privkey.pem')
|
79
|
+
expect { OpenSSL::PKey::RSA.new(object.read) }.to_not raise_error
|
80
|
+
end
|
81
|
+
|
82
|
+
it 'uses server side encryption for everything that is stored' do
|
83
|
+
command.run
|
84
|
+
keys = storage_bucket.keys.select { |k| k.start_with?('example.org/certificates') }
|
85
|
+
expect(keys).to_not be_empty
|
86
|
+
keys.each do |key|
|
87
|
+
object = storage_bucket.object(key)
|
88
|
+
expect(object.options).to include(server_side_encryption: 'AES256')
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
it 'returns an ok exit code' do
|
93
|
+
code = command.run
|
94
|
+
expect(code).to eq(0)
|
95
|
+
end
|
96
|
+
|
97
|
+
context 'when a KMS ID is specified' do
|
98
|
+
let :argv do
|
99
|
+
[
|
100
|
+
'renew',
|
101
|
+
'--domain', 'example.org',
|
102
|
+
'--endpoint', 'http://127.0.0.1:4000',
|
103
|
+
'--site-bucket', 'example-org-site',
|
104
|
+
'--storage-bucket', 'example-org-backup',
|
105
|
+
'--email', 'contact@example.org',
|
106
|
+
'--distribution-id', 'distribution-id',
|
107
|
+
'--kms-id', 'alias/letsencrypt',
|
108
|
+
]
|
109
|
+
end
|
110
|
+
|
111
|
+
it 'uses server side encryption through AWS KMS' do
|
112
|
+
command.run
|
113
|
+
keys = storage_bucket.keys.select { |k| k.start_with?('example.org/certificates') }
|
114
|
+
expect(keys).to_not be_empty
|
115
|
+
keys.each do |key|
|
116
|
+
object = storage_bucket.object(key)
|
117
|
+
expect(object.options).to include(server_side_encryption: 'aws:kms')
|
118
|
+
expect(object.options).to include(ssekms_key_id: 'alias/letsencrypt')
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
context 'when verification fails', vcr: { cassette_name: 'verification-fail' } do
|
125
|
+
it 'prints an error message' do
|
126
|
+
command.run
|
127
|
+
expect(io.string).to match(/unauthorized: Invalid response/)
|
128
|
+
end
|
129
|
+
|
130
|
+
it 'returns a non-ok exit code' do
|
131
|
+
command.run
|
132
|
+
code = command.run
|
133
|
+
expect(code).to eq(1)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
context 'with a non-registered private key', vcr: { cassette_name: 'new-certificate-fail' } do
|
138
|
+
it 'prints an error message' do
|
139
|
+
command.run
|
140
|
+
expect(io.string.chomp).to eq('No registration exists matching provided key (Acme::Error::Unauthorized)')
|
141
|
+
end
|
142
|
+
|
143
|
+
it 'returns a non-ok exit code' do
|
144
|
+
code = command.run
|
145
|
+
expect(code).to eq(1)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Lester
|
4
|
+
describe Authenticator do
|
5
|
+
let :authenticator do
|
6
|
+
described_class.new(bucket, sleeper: sleeper)
|
7
|
+
end
|
8
|
+
|
9
|
+
let :challenge do
|
10
|
+
double(:challenge)
|
11
|
+
end
|
12
|
+
|
13
|
+
let :bucket do
|
14
|
+
double(:bucket)
|
15
|
+
end
|
16
|
+
|
17
|
+
let :sleeper do
|
18
|
+
double(:sleeper)
|
19
|
+
end
|
20
|
+
|
21
|
+
before do
|
22
|
+
allow(challenge).to receive(:filename).and_return('challenge-filename')
|
23
|
+
allow(challenge).to receive(:file_content).and_return('challenge-file-content')
|
24
|
+
allow(challenge).to receive(:content_type).and_return('text/plain')
|
25
|
+
allow(challenge).to receive(:request_verification).and_return(true)
|
26
|
+
allow(challenge).to receive(:status).and_return('valid')
|
27
|
+
allow(challenge).to receive(:error)
|
28
|
+
allow(bucket).to receive(:put_object)
|
29
|
+
allow(sleeper).to receive(:sleep)
|
30
|
+
end
|
31
|
+
|
32
|
+
describe '#authenticate' do
|
33
|
+
it 'uses the challenge\'s filename as S3 key' do
|
34
|
+
authenticator.authenticate(challenge)
|
35
|
+
expect(bucket).to have_received(:put_object).with(hash_including(key: 'challenge-filename'))
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'writes the challenge\'s content' do
|
39
|
+
authenticator.authenticate(challenge)
|
40
|
+
expect(bucket).to have_received(:put_object).with(hash_including(body: 'challenge-file-content'))
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'uses `public-read` as acl' do
|
44
|
+
authenticator.authenticate(challenge)
|
45
|
+
expect(bucket).to have_received(:put_object).with(hash_including(acl: 'public-read'))
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'requests verification' do
|
49
|
+
authenticator.authenticate(challenge)
|
50
|
+
expect(challenge).to have_received(:request_verification)
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'raises an error when verification is refused' do
|
54
|
+
allow(challenge).to receive(:request_verification).and_return(false)
|
55
|
+
expect { authenticator.authenticate(challenge) }.to raise_error(RequestVerificationError)
|
56
|
+
end
|
57
|
+
|
58
|
+
context 'request verification' do
|
59
|
+
it 'verifies the status of the challenge' do
|
60
|
+
allow(challenge).to receive(:status).and_return('pending')
|
61
|
+
allow(challenge).to receive(:verify_status) do
|
62
|
+
allow(challenge).to receive(:status).and_return('valid')
|
63
|
+
end
|
64
|
+
authenticator.authenticate(challenge)
|
65
|
+
expect(challenge).to have_received(:verify_status)
|
66
|
+
end
|
67
|
+
|
68
|
+
it 'raises an error when the verification returns an error' do
|
69
|
+
allow(challenge).to receive(:error).and_return('type' => 'urn:acme:error:unauthorized', 'detail' => 'Invalid response: 404')
|
70
|
+
expect { authenticator.authenticate(challenge) }.to raise_error(RequestVerificationError, 'urn:acme:error:unauthorized: Invalid response: 404')
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'raises an error when the verification status is != valid' do
|
74
|
+
allow(challenge).to receive(:status).and_return('invalid')
|
75
|
+
expect { authenticator.authenticate(challenge) }.to raise_error(RequestVerificationError, 'invalid')
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Lester
|
4
|
+
describe Cli do
|
5
|
+
let :cli do
|
6
|
+
described_class.new(argv, io)
|
7
|
+
end
|
8
|
+
|
9
|
+
let :io do
|
10
|
+
StringIO.new
|
11
|
+
end
|
12
|
+
|
13
|
+
let :output do
|
14
|
+
io.string
|
15
|
+
end
|
16
|
+
|
17
|
+
describe 'help|-h' do
|
18
|
+
let :argv do
|
19
|
+
['help']
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'prints usage' do
|
23
|
+
cli.run
|
24
|
+
expect(output).to match(/Usage/)
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'returns an ok exit code' do
|
28
|
+
code = cli.run
|
29
|
+
expect(code).to eq(0)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
describe 'init' do
|
34
|
+
let :command_name do
|
35
|
+
'init'
|
36
|
+
end
|
37
|
+
|
38
|
+
context 'cli parameters' do
|
39
|
+
context '-d / --domain DOMAIN' do
|
40
|
+
parameter_validation 'domain'
|
41
|
+
end
|
42
|
+
|
43
|
+
context '-s / --storage-bucket NAME' do
|
44
|
+
parameter_validation 'storage-bucket', 'storage bucket'
|
45
|
+
end
|
46
|
+
|
47
|
+
context '-p / --private-key PATH' do
|
48
|
+
parameter_validation 'private-key', 'private key path'
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
describe 'renew|new' do
|
54
|
+
let :command_name do
|
55
|
+
'renew'
|
56
|
+
end
|
57
|
+
|
58
|
+
context 'cli parameters' do
|
59
|
+
context '-d / --domain DOMAIN' do
|
60
|
+
parameter_validation 'domain'
|
61
|
+
end
|
62
|
+
|
63
|
+
context '-s / --storage-bucket NAME' do
|
64
|
+
parameter_validation 'storage-bucket', 'storage bucket'
|
65
|
+
end
|
66
|
+
|
67
|
+
context '-b / --site-bucket NAME' do
|
68
|
+
parameter_validation 'site-bucket', 'site bucket'
|
69
|
+
end
|
70
|
+
|
71
|
+
context '-e / --email ADDRESS' do
|
72
|
+
parameter_validation 'email'
|
73
|
+
end
|
74
|
+
|
75
|
+
context '-D / --distribution-id ID' do
|
76
|
+
parameter_validation 'distribution-id', 'distribution id'
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
describe 'any other command' do
|
82
|
+
let :argv do
|
83
|
+
['other']
|
84
|
+
end
|
85
|
+
|
86
|
+
it 'prints an error message' do
|
87
|
+
cli.run
|
88
|
+
expect(output).to match(/Unknown command "other"/)
|
89
|
+
end
|
90
|
+
|
91
|
+
it 'prints usage' do
|
92
|
+
cli.run
|
93
|
+
expect(output).to match(/Usage/)
|
94
|
+
end
|
95
|
+
|
96
|
+
it 'returns a non-ok exit code' do
|
97
|
+
code = cli.run
|
98
|
+
expect(code).to eq(1)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Lester
|
4
|
+
module Command
|
5
|
+
describe Init do
|
6
|
+
let :command do
|
7
|
+
described_class.new(private_key, store)
|
8
|
+
end
|
9
|
+
|
10
|
+
let :private_key do
|
11
|
+
double(:private_key)
|
12
|
+
end
|
13
|
+
|
14
|
+
let :store do
|
15
|
+
double(:store)
|
16
|
+
end
|
17
|
+
|
18
|
+
before do
|
19
|
+
allow(private_key).to receive(:to_jwk).and_return({})
|
20
|
+
allow(store).to receive(:put)
|
21
|
+
end
|
22
|
+
|
23
|
+
describe '#run' do
|
24
|
+
it 'unconditionally stores the key in JSON JWK format' do
|
25
|
+
command.run
|
26
|
+
expect(store).to have_received(:put).with('private_key.json', '{}')
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|