lester 1.0.0.pre1

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