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.
@@ -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
@@ -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).
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $: << 'lib'
4
+
5
+ require 'lester'
6
+
7
+
8
+ exit(Lester::Cli.new(ARGV).run)
@@ -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
@@ -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,7 @@
1
+ module Lester
2
+ module Command
3
+ end
4
+ end
5
+
6
+ require 'lester/command/init'
7
+ require 'lester/command/renew'
@@ -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