lester 1.0.0.pre1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|