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
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: c17183ee2fb1a8f5b7001d8dca46b6d14ab655e5
|
4
|
+
data.tar.gz: 714db6829427e804e423a0012c873eb893c29846
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c3f392540c9b2f5a8d8322eb3e69df96ad068cc756e3dd85c55fdc9b50603686496d5485564d76cc4d70e02d5a4a340491f3e184f930e735a331176b1badb089
|
7
|
+
data.tar.gz: 1acf0d620e036b9d2081af46ec2dda0d3cb708053e102e30cb296515cd73e478a2c6c153fcf1469e0ef69b4d00b5180a6b7e16350169f40545b0e8aa49d73e89
|
data/README.md
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
# lester
|
2
|
+
|
3
|
+
[](https://travis-ci.org/mthssdrbrg/lester)
|
4
|
+
|
5
|
+
|
6
|
+
Lester is a small tool for renewing certificates from Let's Encrypt (or any
|
7
|
+
ACME-compatible server), for websites set up using S3 and CloudFront.
|
8
|
+
|
9
|
+
It uses S3 for storing certificates and expects that the private key for a
|
10
|
+
registered account is available from S3. Server side encryption is enabled by
|
11
|
+
default for all objects written by Lester, and it's possible to use KMS as well.
|
12
|
+
|
13
|
+
Should be noted that even though only a single domain is passed to `lester`, it
|
14
|
+
will actually include both the given domain and the `www` subdomain (i.e. `www.<domain>`)
|
15
|
+
when requesting a new certificate.
|
16
|
+
|
17
|
+
# Installation
|
18
|
+
|
19
|
+
```shell
|
20
|
+
gem install lester --pre
|
21
|
+
```
|
22
|
+
|
23
|
+
# Usage
|
24
|
+
|
25
|
+
To get started and upload a local private key the following command can be used:
|
26
|
+
|
27
|
+
```shell
|
28
|
+
lester init --domain example.org \
|
29
|
+
--storage-bucket example-org-backup \
|
30
|
+
--private-key privkey.pem
|
31
|
+
```
|
32
|
+
|
33
|
+
To generate a new certificate, the simplest invocation of `lester` is the
|
34
|
+
following:
|
35
|
+
|
36
|
+
```shell
|
37
|
+
lester new --domain example.org \
|
38
|
+
--site-bucket example-org \
|
39
|
+
--storage-bucket example-org-backup \
|
40
|
+
--email contact@example.org \
|
41
|
+
--distribution-id ABCDEFGH
|
42
|
+
```
|
43
|
+
|
44
|
+
To enable server side encryption with KMS, one can specify the `-k / --kms-id`
|
45
|
+
with eiither a key ID or alias:
|
46
|
+
|
47
|
+
```shell
|
48
|
+
lester new --domain example.org \
|
49
|
+
--site-bucket example-org \
|
50
|
+
--storage-bucket example-org-backup \
|
51
|
+
--email contact@example.org \
|
52
|
+
--distribution-id ABCDEFGH \
|
53
|
+
--kms-id alias/letsencrypt
|
54
|
+
```
|
55
|
+
|
56
|
+
It's also possible to use `renew` rather than `new` if one prefers, the result
|
57
|
+
will be the same.
|
58
|
+
|
59
|
+
See `lester --help` for information about other command-line parameters.
|
60
|
+
|
61
|
+
## Copyright
|
62
|
+
|
63
|
+
© 2015 Mathias Söderberg, see LICENSE.txt (MIT).
|
data/bin/lester
ADDED
data/lib/lester.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'acme-client'
|
2
|
+
require 'aws-sdk'
|
3
|
+
require 'json'
|
4
|
+
require 'pathname'
|
5
|
+
|
6
|
+
module Lester
|
7
|
+
Error = Class.new(StandardError)
|
8
|
+
RequestVerificationError = Class.new(Error)
|
9
|
+
RequiredArgumentError = Class.new(Error)
|
10
|
+
UnkownCommandError = Class.new(Error)
|
11
|
+
UnknownKeyFormatError = Class.new(Error)
|
12
|
+
|
13
|
+
KEY_NAME = 'private_key.json'.freeze
|
14
|
+
end
|
15
|
+
|
16
|
+
require 'lester/authenticator'
|
17
|
+
require 'lester/cli'
|
18
|
+
require 'lester/command'
|
19
|
+
require 'lester/factory'
|
20
|
+
require 'lester/private_key'
|
21
|
+
require 'lester/s3_store'
|
22
|
+
require 'lester/uploader'
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Lester
|
2
|
+
class Authenticator
|
3
|
+
def initialize(bucket, options={})
|
4
|
+
@bucket = bucket
|
5
|
+
@sleeper = options[:sleeper] || Kernel
|
6
|
+
end
|
7
|
+
|
8
|
+
def authenticate(challenge)
|
9
|
+
write(challenge)
|
10
|
+
verify_status(challenge)
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def write(challenge)
|
16
|
+
object = {
|
17
|
+
key: challenge.filename,
|
18
|
+
body: challenge.file_content,
|
19
|
+
content_type: challenge.content_type,
|
20
|
+
acl: 'public-read'
|
21
|
+
}
|
22
|
+
@bucket.put_object(object)
|
23
|
+
end
|
24
|
+
|
25
|
+
def verify_status(challenge)
|
26
|
+
if challenge.request_verification
|
27
|
+
until challenge.status != 'pending' || challenge.error do
|
28
|
+
@sleeper.sleep(1)
|
29
|
+
challenge.verify_status
|
30
|
+
end
|
31
|
+
if (error = challenge.error)
|
32
|
+
raise RequestVerificationError, sprintf('%s: %s', error['type'], error['detail'])
|
33
|
+
end
|
34
|
+
if (status = challenge.status) != 'valid'
|
35
|
+
raise RequestVerificationError, status
|
36
|
+
end
|
37
|
+
else
|
38
|
+
raise RequestVerificationError
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
data/lib/lester/cli.rb
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
module Lester
|
4
|
+
class Cli
|
5
|
+
def initialize(argv=ARGV, io=$stderr, option={})
|
6
|
+
@command = argv.shift || 'help'
|
7
|
+
@argv = argv
|
8
|
+
@io = io
|
9
|
+
@key_size = 2048
|
10
|
+
@endpoint = 'https://acme-v01.api.letsencrypt.org/'
|
11
|
+
end
|
12
|
+
|
13
|
+
def run
|
14
|
+
parse_argv
|
15
|
+
dispatch
|
16
|
+
0
|
17
|
+
rescue OptionParser::ParseError, RequiredArgumentError, UnkownCommandError => e
|
18
|
+
@io.puts(sprintf('%s (%s)', e.message, e.class.name))
|
19
|
+
@io.puts(option_parser)
|
20
|
+
1
|
21
|
+
rescue => e
|
22
|
+
@io.puts(sprintf('%s (%s)', e.message, e.class.name))
|
23
|
+
1
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
HELP_REGEX = /help|-h/.freeze
|
29
|
+
|
30
|
+
def parse_argv
|
31
|
+
option_parser.parse(@argv)
|
32
|
+
unless @command =~ HELP_REGEX
|
33
|
+
case @command
|
34
|
+
when 'init'
|
35
|
+
validate(@domain, 'domain is required')
|
36
|
+
validate(@storage_bucket, 'storage bucket is required')
|
37
|
+
validate(@private_key_path, 'private key path is required')
|
38
|
+
when 'new', 'renew'
|
39
|
+
validate(@domain, 'domain is required')
|
40
|
+
validate(@storage_bucket, 'storage bucket is required')
|
41
|
+
validate(@site_bucket, 'site bucket is required')
|
42
|
+
validate(@email, 'email is required')
|
43
|
+
validate(@distribution_id, 'distribution id is required')
|
44
|
+
else
|
45
|
+
raise UnkownCommandError, sprintf('Unknown command %p, expected "init" or "re|new"', @command)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def validate(arg, message)
|
51
|
+
if arg.nil? || arg.empty?
|
52
|
+
raise RequiredArgumentError, message
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def dispatch
|
57
|
+
case @command
|
58
|
+
when HELP_REGEX
|
59
|
+
@io.puts(option_parser)
|
60
|
+
when 'init'
|
61
|
+
Command::Init.create(factory).run
|
62
|
+
when 'new', 'renew'
|
63
|
+
Command::Renew.create(@domain, @key_size, factory).run
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def option_parser
|
68
|
+
@option_parser ||= OptionParser.new do |opts|
|
69
|
+
opts.banner = %(Usage: lester init [options]\n lester new [options])
|
70
|
+
opts.separator ''
|
71
|
+
opts.separator 'Common options:'
|
72
|
+
opts.on('-d', '--domain=NAME', 'Domain name (required)') { |d| @domain = d }
|
73
|
+
opts.on('-s', '--storage-bucket=BUCKET', 'S3 bucket for storing keys and certificates (required)') { |b| @storage_bucket = b }
|
74
|
+
opts.on('-K', '--kms-id=KEY_ID', 'AWS KMS Key ID') { |k| @kms_key_id = k }
|
75
|
+
opts.separator ''
|
76
|
+
opts.separator 'init options:'
|
77
|
+
opts.on('-p', '--private-key=PATH', 'Path to private key (required)') { |p| @private_key_path = p }
|
78
|
+
opts.separator ''
|
79
|
+
opts.separator 're|new options:'
|
80
|
+
opts.on('-E', '--endpoint=ENDPOINT', sprintf('ACME endpoint (default: %s)', @endpoint)) { |e| @endpoint = e }
|
81
|
+
opts.on('-b', '--site-bucket=BUCKET', 'S3 bucket for site (required)') { |b| @site_bucket = b }
|
82
|
+
opts.on('-k', '--key-size=BITS', sprintf('Key size (in bits) (default: %d)', @key_size)) { |s| @key_size = s.to_i }
|
83
|
+
opts.on('-e', '--email=ADDRESS', 'Registered email address (required)') { |e| @email = e }
|
84
|
+
opts.on('-D', '--distribution-id=ID', 'CloudFront distribution ID (required)') { |d| @distribution_id = d }
|
85
|
+
opts.separator ''
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def factory
|
90
|
+
@factory ||= Factory.new({
|
91
|
+
domain: @domain,
|
92
|
+
storage_bucket: @storage_bucket,
|
93
|
+
kms_key_id: @kms_key_id,
|
94
|
+
endpoint: @endpoint,
|
95
|
+
site_bucket: @site_bucket,
|
96
|
+
private_key_path: @private_key_path,
|
97
|
+
distribution_id: @distribution_id,
|
98
|
+
})
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Lester
|
2
|
+
module Command
|
3
|
+
class Init
|
4
|
+
def self.create(factory)
|
5
|
+
new(factory.private_key, factory.account_store)
|
6
|
+
end
|
7
|
+
|
8
|
+
def initialize(private_key, store)
|
9
|
+
@private_key = private_key
|
10
|
+
@store = store
|
11
|
+
end
|
12
|
+
|
13
|
+
def run
|
14
|
+
@store.put(KEY_NAME, @private_key.to_jwk.to_json)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module Lester
|
2
|
+
module Command
|
3
|
+
class Renew
|
4
|
+
def self.create(domain, key_size, factory)
|
5
|
+
new(domain, factory.acme_client, factory.authenticator, factory.uploader, factory.certificate_store, key_size: key_size)
|
6
|
+
end
|
7
|
+
|
8
|
+
def initialize(domain, acme_client, authenticator, uploader, store, options={})
|
9
|
+
@domain = domain
|
10
|
+
@acme_client = acme_client
|
11
|
+
@authenticator = authenticator
|
12
|
+
@uploader = uploader
|
13
|
+
@store = store
|
14
|
+
@key_size = options[:key_size] || 2048
|
15
|
+
@key_class = options[:key_class] || OpenSSL::PKey::RSA
|
16
|
+
@csr_class = options[:csr_class] || Acme::CertificateRequest
|
17
|
+
end
|
18
|
+
|
19
|
+
def run
|
20
|
+
auth
|
21
|
+
renew
|
22
|
+
upload
|
23
|
+
store
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def auth
|
29
|
+
domains.each do |domain|
|
30
|
+
authorization = @acme_client.authorize(domain: domain)
|
31
|
+
@authenticator.authenticate(authorization.http01)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def renew
|
36
|
+
key = @key_class.new(@key_size)
|
37
|
+
@csr = @csr_class.new(names: domains, private_key: key)
|
38
|
+
@certificate = @acme_client.new_certificate(@csr)
|
39
|
+
end
|
40
|
+
|
41
|
+
def upload
|
42
|
+
certificate_name = sprintf('%s-%s', @domain, issue_date)
|
43
|
+
@uploader.upload(certificate_name, @certificate, @csr.private_key)
|
44
|
+
end
|
45
|
+
|
46
|
+
def store
|
47
|
+
@store.put(sprintf('%s/cert.pem', issue_date), @certificate.to_pem)
|
48
|
+
@store.put(sprintf('%s/chain.pem', issue_date), @certificate.chain_to_pem)
|
49
|
+
@store.put(sprintf('%s/fullchain.pem', issue_date), @certificate.fullchain_to_pem)
|
50
|
+
@store.put(sprintf('%s/csr.pem', issue_date), @csr.to_pem)
|
51
|
+
@store.put(sprintf('%s/privkey.pem', issue_date), @csr.private_key.to_pem)
|
52
|
+
end
|
53
|
+
|
54
|
+
def issue_date
|
55
|
+
@issue_date ||= @certificate.x509.not_before.strftime('%Y%m%d%H%M')
|
56
|
+
end
|
57
|
+
|
58
|
+
def domains
|
59
|
+
@domains ||= [@domain, sprintf('www.%s', @domain)]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
module Lester
|
2
|
+
class Factory
|
3
|
+
def initialize(config)
|
4
|
+
@config = config
|
5
|
+
end
|
6
|
+
|
7
|
+
def authenticator
|
8
|
+
@authenticator ||= Authenticator.new(site_bucket)
|
9
|
+
end
|
10
|
+
|
11
|
+
def uploader
|
12
|
+
@uploader ||= Uploader.new(iam, cloudfront, @config[:distribution_id])
|
13
|
+
end
|
14
|
+
|
15
|
+
def acme_client
|
16
|
+
@acme_client ||= Acme::Client.new(private_key: private_key, endpoint: @config[:endpoint])
|
17
|
+
end
|
18
|
+
|
19
|
+
def account_store
|
20
|
+
@account_store ||= create_store('account')
|
21
|
+
end
|
22
|
+
|
23
|
+
def certificate_store
|
24
|
+
@certificate_store ||= create_store('certificates')
|
25
|
+
end
|
26
|
+
|
27
|
+
def private_key
|
28
|
+
@private_key ||= begin
|
29
|
+
if (key_path = @config[:private_key_path])
|
30
|
+
PrivateKey.new(Pathname.new(key_path)).load
|
31
|
+
else
|
32
|
+
PrivateKey.new(account_store.get(KEY_NAME)).load
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def create_store(suffix)
|
40
|
+
S3Store.new(storage_bucket, sprintf('%s/%s', @config[:domain], suffix), store_options)
|
41
|
+
end
|
42
|
+
|
43
|
+
def store_options
|
44
|
+
@store_options ||= begin
|
45
|
+
options = {server_side_encryption: 'AES256'}
|
46
|
+
if (kms_key_id = @config[:kms_key_id])
|
47
|
+
options[:server_side_encryption] = 'aws:kms'
|
48
|
+
options[:ssekms_key_id] = kms_key_id
|
49
|
+
end
|
50
|
+
options
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def site_bucket
|
55
|
+
@site_bucket ||= Aws::S3::Bucket.new(@config[:site_bucket])
|
56
|
+
end
|
57
|
+
|
58
|
+
def storage_bucket
|
59
|
+
@storage_bucket ||= Aws::S3::Bucket.new(@config[:storage_bucket])
|
60
|
+
end
|
61
|
+
|
62
|
+
def iam
|
63
|
+
@iam ||= Aws::IAM::Client.new
|
64
|
+
end
|
65
|
+
|
66
|
+
def cloudfront
|
67
|
+
@cloudfront ||= Aws::CloudFront::Client.new
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Lester
|
2
|
+
class PrivateKey
|
3
|
+
def initialize(path)
|
4
|
+
@path = path
|
5
|
+
end
|
6
|
+
|
7
|
+
def load
|
8
|
+
case @path.extname
|
9
|
+
when '.json'
|
10
|
+
JSON::JWK.new(JSON.parse(@path.read)).to_key
|
11
|
+
when '.pem'
|
12
|
+
OpenSSL::PKey::RSA.new(@path.read)
|
13
|
+
else
|
14
|
+
raise UnknownKeyFormatError, @path
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Lester
|
2
|
+
class S3Store
|
3
|
+
def initialize(bucket, prefix, options={})
|
4
|
+
@bucket = bucket
|
5
|
+
@prefix = prefix
|
6
|
+
@options = options
|
7
|
+
end
|
8
|
+
|
9
|
+
def get(name)
|
10
|
+
Proxy.new(@bucket.object(format_name(name)))
|
11
|
+
end
|
12
|
+
|
13
|
+
def put(name, contents)
|
14
|
+
@bucket.put_object(@options.merge(key: format_name(name), body: contents))
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def format_name(name)
|
20
|
+
sprintf('%s/%s', @prefix, name)
|
21
|
+
end
|
22
|
+
|
23
|
+
class Proxy
|
24
|
+
def initialize(object)
|
25
|
+
@path = Pathname.new(object.key)
|
26
|
+
@object = object
|
27
|
+
end
|
28
|
+
|
29
|
+
def exists?
|
30
|
+
@object.exists?
|
31
|
+
end
|
32
|
+
|
33
|
+
def read
|
34
|
+
@object.get.body.read
|
35
|
+
end
|
36
|
+
|
37
|
+
def extname
|
38
|
+
@path.extname
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|