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
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
|
+
[![Build Status](https://travis-ci.org/mthssdrbrg/lester.svg?branch=master)](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
|