letsencrypt_heroku 0.1.3 → 0.2.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 2cac0b25f72c3190be77975f3a93dec7041cc3a0
4
- data.tar.gz: c02456232fa21321e1b6edda57f4ecd49e2e7973
3
+ metadata.gz: 84cc20b0f946f057dbb97dbc47775fb65e34841a
4
+ data.tar.gz: da5efa37f2ae9edb0c937d8b3badb0eab32ed147
5
5
  SHA512:
6
- metadata.gz: 1dbeb5d5b31f62654d02085d2e62423ea4b0c04c56a10514ffa2e6518ef3cd09f3279231825fe3b2c0a4101efa7fb7ab48a25c3d5bad298b327a0d76cb588186
7
- data.tar.gz: 07fe6c940ecb08a24937c8e34b18c720a16b28fe0151664a6832328b26db4580c47df48cace4a4c662d3f0fea5398a9820592e8013e57c74884aeeca7316f81b
6
+ metadata.gz: ee3808965a511242b527a62118c2d8f06a2b1b5bf6368aeb439973871d1b365dc751b7e27e2073315eda3ca30082728c484d24aa6eb3268d58cce9f511d582b8
7
+ data.tar.gz: 1ffcd1a23790403c2a372b2f36c8e80d7d3f121635093200b68feaad6d88dcbb61b18a524fa9e3712b7a36464597309e275464ea3803983579297e71ffe2e59d
data/.gitignore CHANGED
@@ -7,3 +7,4 @@
7
7
  /pkg/
8
8
  /spec/reports/
9
9
  /tmp/
10
+ /log/
data/README.md CHANGED
@@ -19,13 +19,19 @@ You'll need a `config/letsencrypt_heroku.yml`
19
19
 
20
20
  - contact: contact@foobar.dev
21
21
  domains: foobar.dev www.foobar.dev
22
- herokuapp: foobar
22
+ heroku_app: foobar
23
23
 
24
24
  And finally execute
25
25
 
26
26
  $ letsencrypt_heroku
27
27
 
28
+ Please note that your application needs to be restarted for every individual domain in your config. The restart happens
29
+ automatically when the heroku challenge response gets set as environment variable.
30
+
28
31
  ## Contributing
29
32
 
30
33
  Bug reports and pull requests are welcome on GitHub at https://github.com/xijo/letsencrypt_heroku.
31
34
 
35
+ ## TODO
36
+
37
+ - configurable config file location
@@ -2,5 +2,39 @@
2
2
 
3
3
  require 'bundler/setup'
4
4
  require 'letsencrypt_heroku'
5
+ require 'optparse'
6
+
7
+ class Parser
8
+ def self.parse(options)
9
+ args = {}
10
+
11
+ opt_parser = OptionParser.new do |opts|
12
+ opts.banner = "Usage: letsencrypt_heroku [options]"
13
+
14
+ # opts.on("-rNAME", "--restriction=NAME", "Restrict process to config entries where at least one domain matches the given value") do |restriction|
15
+ # args[:restriction] = restriction
16
+ # end
17
+
18
+ # opts.on("-s", "--setup", "Generate config and put it into config/letsencrypt_heroku.yml") do |n|
19
+ # LetsencryptHeroku::ConfigBuilder.new.perform
20
+ # exit
21
+ # end
22
+
23
+ # opts.on("-c", "--check", "Parse config and print problems") do |n|
24
+ # puts "perform check"
25
+ # exit
26
+ # end
27
+
28
+ opts.on("-h", "--help", "Prints this help") do
29
+ puts opts
30
+ exit
31
+ end
32
+ end
33
+
34
+ opt_parser.parse!(options)
35
+ return args
36
+ end
37
+ end
38
+ options = Parser.parse ARGV
5
39
 
6
40
  LetsencryptHeroku::CLI.run
@@ -3,20 +3,36 @@ require 'tty-spinner'
3
3
  require 'yaml'
4
4
  require 'acme-client'
5
5
  require 'openssl'
6
+ require 'logger'
7
+ require 'ostruct'
6
8
  require 'letsencrypt_heroku/version'
7
- require 'letsencrypt_heroku/setup'
9
+ require 'letsencrypt_heroku/tools'
10
+ require 'letsencrypt_heroku/process'
11
+ require 'letsencrypt_heroku/config_builder'
12
+ require 'letsencrypt_heroku/process/prepare_config'
13
+ require 'letsencrypt_heroku/process/check_preconditions'
14
+ require 'letsencrypt_heroku/process/setup_client'
15
+ require 'letsencrypt_heroku/process/authorize_domains'
16
+ require 'letsencrypt_heroku/process/update_certificates'
8
17
 
9
18
  module LetsencryptHeroku
10
19
  class CLI
11
20
  CONFIG_FILE = 'config/letsencrypt_heroku.yml'
12
21
 
22
+ # generate config?
23
+
24
+ # limit to domain, skip other configs
25
+
13
26
  def self.run
14
27
  if File.exist?(CONFIG_FILE)
15
28
  configs = Array(YAML.load(File.read(CONFIG_FILE))).map { |c| OpenStruct.new(c) }
16
- configs.each { |config| Setup.new(config).perform }
29
+ configs.each { |config| Process.new(config).perform }
17
30
  else
18
31
  puts Rainbow("Missing config: #{CONFIG_FILE}").red
19
32
  end
20
33
  end
21
34
  end
35
+
36
+ class TaskError < StandardError
37
+ end
22
38
  end
@@ -0,0 +1,14 @@
1
+ class LetsencryptHeroku::ConfigBuilder
2
+ include LetsencryptHeroku::Tools
3
+
4
+ # Create a letsencrypt_heroku config file under the given path.
5
+ #
6
+ def perform(path = 'config/letsencrypt_heroku.yml')
7
+ banner 'Generate:', "#{Dir.pwd}/#{path}"
8
+
9
+ output("write file") do
10
+ error('already exists') if File.exists?(path)
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ module LetsencryptHeroku
2
+ class Process
3
+ attr_accessor :context
4
+
5
+ def initialize(config)
6
+ @context = OpenStruct.new(config: config)
7
+ end
8
+
9
+ def perform
10
+ [PrepareConfig, CheckPreconditions, SetupClient, AuthorizeDomains, UpdateCertificates].each do |klass|
11
+ klass.new.perform(context)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,38 @@
1
+ class LetsencryptHeroku::Process
2
+ class AuthorizeDomains
3
+ include LetsencryptHeroku::Tools
4
+
5
+ def perform(context)
6
+ context.config.domains.each do |domain|
7
+ output "Authorize #{domain}" do
8
+ authorize(domain: domain, context: context)
9
+ end
10
+ end
11
+ end
12
+
13
+ def authorize(domain:, context:)
14
+ challenge = context.client.authorize(domain: domain).http01
15
+
16
+ execute "heroku config:set LETSENCRYPT_RESPONSE=#{challenge.file_content} --app #{context.config.heroku_challenge_app}"
17
+
18
+ test_response(domain: domain, challenge: challenge)
19
+
20
+ challenge.request_verification
21
+ sleep(1) while 'pending' == challenge.verify_status
22
+ challenge.verify_status == 'valid' or error('failed authorization')
23
+ end
24
+
25
+ def test_response(domain:, challenge:)
26
+ url = "http://#{domain}/#{challenge.filename}"
27
+ fail_count = 0
28
+ answer = nil
29
+
30
+ while answer != challenge.file_content
31
+ error('failed test response') if fail_count > 30
32
+ fail_count += 1
33
+ sleep(1)
34
+ answer = `curl -sL #{url}`
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,15 @@
1
+ class LetsencryptHeroku::Process
2
+ class CheckPreconditions
3
+ include LetsencryptHeroku::Tools
4
+
5
+ def perform(context)
6
+ banner 'Running for:', context.config.domains.join(', ')
7
+ output 'Prepare SSL endpoint' do
8
+ execute "heroku labs:enable http-sni --app #{context.config.heroku_certificate_app}"
9
+ execute 'heroku plugins:install heroku-certs'
10
+ end
11
+ end
12
+ end
13
+ end
14
+
15
+
@@ -0,0 +1,25 @@
1
+ class LetsencryptHeroku::Process
2
+ class PrepareConfig
3
+ include LetsencryptHeroku::Tools
4
+
5
+ def perform(context)
6
+ config = context.config
7
+ config.domains = config.domains.to_s.split
8
+ config.heroku_certificate_app ||= config.heroku_app
9
+ config.heroku_challenge_app ||= config.heroku_app
10
+
11
+ validate_config(config)
12
+ context
13
+ end
14
+
15
+ def validate_config(config)
16
+ if config.domains.empty?
17
+ error('Please provide `domains`')
18
+ end
19
+
20
+ unless config.heroku_certificate_app && config.heroku_challenge_app
21
+ error('Please provide `heroku_app`')
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,24 @@
1
+ class LetsencryptHeroku::Process
2
+ class SetupClient
3
+ include LetsencryptHeroku::Tools
4
+
5
+ PRODUCTION = 'https://acme-v01.api.letsencrypt.org/'
6
+ STAGING = 'https://acme-staging.api.letsencrypt.org/'
7
+
8
+ def perform(context)
9
+ output 'Contact letsencrypt server' do
10
+ context.client = build_client
11
+ context.client.register(contact: "mailto:#{context.config.contact}").agree_terms or error('registration failed')
12
+ end
13
+ end
14
+
15
+ def build_client
16
+ Acme::Client.new(private_key: private_key, endpoint: PRODUCTION)
17
+ end
18
+
19
+ def private_key
20
+ OpenSSL::PKey::RSA.new(4096)
21
+ end
22
+ end
23
+ end
24
+
@@ -0,0 +1,30 @@
1
+ class LetsencryptHeroku::Process
2
+ class UpdateCertificates
3
+ include LetsencryptHeroku::Tools
4
+
5
+ def perform(context)
6
+ herokuapp = context.config.heroku_certificate_app
7
+
8
+ output 'Update certificates' do
9
+ csr = Acme::Client::CertificateRequest.new(names: context.config.domains)
10
+ certificate = context.client.new_certificate(csr)
11
+ File.write('privkey.pem', certificate.request.private_key.to_pem)
12
+ File.write('fullchain.pem', certificate.fullchain_to_pem)
13
+
14
+ if has_already_cert(herokuapp)
15
+ execute "heroku _certs:update fullchain.pem privkey.pem --confirm #{herokuapp} --app #{herokuapp}"
16
+ else
17
+ execute "heroku _certs:add fullchain.pem privkey.pem --app #{herokuapp}"
18
+ end
19
+ FileUtils.rm %w(privkey.pem fullchain.pem)
20
+ end
21
+ puts # finish the output with a nice newline ;)
22
+ end
23
+
24
+ def has_already_cert(herokuapp)
25
+ Open3.popen3("heroku _certs:info --app #{herokuapp}") do |stdin, stdout, stderr, wait_thr|
26
+ return wait_thr.value.success?
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,54 @@
1
+ module LetsencryptHeroku
2
+ module Tools
3
+ def banner(msg, values = nil)
4
+ puts "\n #{Rainbow(msg).blue} #{values.to_s}\n\n"
5
+ end
6
+
7
+ def output(name)
8
+ log name
9
+ @_spinner = build_spinner(name)
10
+ @_spinner.start
11
+ yield
12
+ @_spinner.success
13
+ rescue LetsencryptHeroku::TaskError
14
+ exit
15
+ end
16
+
17
+ def log(message, level: :info)
18
+ message.to_s.empty? and return
19
+ level == :info ? logger.info(message) : logger.error(message)
20
+ end
21
+
22
+ def error(reason = nil)
23
+ log reason, level: :error
24
+ @_spinner && @_spinner.error("(#{reason.strip})")
25
+ raise LetsencryptHeroku::TaskError, reason
26
+ end
27
+
28
+ def execute(command)
29
+ log command
30
+ Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
31
+ out, err = stdout.read, stderr.read
32
+ log out
33
+ log err
34
+ wait_thr.value.success? or error(err.force_encoding('utf-8').sub(' ▸ ', 'heroku: '))
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def logger
41
+ @logger ||= Logger.new(File.open('log/letsencrypt_heroku.log', File::WRONLY | File::APPEND | File::CREAT))
42
+ end
43
+
44
+ def build_spinner(name)
45
+ TTY::Spinner.new(" :spinner #{name}",
46
+ format: :dots,
47
+ interval: 20,
48
+ frames: [ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" ].map { |s| Rainbow(s).yellow.bright },
49
+ success_mark: Rainbow('✔').green,
50
+ error_mark: Rainbow('✘').red
51
+ )
52
+ end
53
+ end
54
+ end
@@ -1,3 +1,3 @@
1
1
  module LetsencryptHeroku
2
- VERSION = "0.1.3"
2
+ VERSION = "0.2.3"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: letsencrypt_heroku
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Opper
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-07-17 00:00:00.000000000 Z
11
+ date: 2016-08-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rainbow
@@ -128,7 +128,14 @@ files:
128
128
  - bin/setup
129
129
  - letsencrypt_heroku.gemspec
130
130
  - lib/letsencrypt_heroku.rb
131
- - lib/letsencrypt_heroku/setup.rb
131
+ - lib/letsencrypt_heroku/config_builder.rb
132
+ - lib/letsencrypt_heroku/process.rb
133
+ - lib/letsencrypt_heroku/process/authorize_domains.rb
134
+ - lib/letsencrypt_heroku/process/check_preconditions.rb
135
+ - lib/letsencrypt_heroku/process/prepare_config.rb
136
+ - lib/letsencrypt_heroku/process/setup_client.rb
137
+ - lib/letsencrypt_heroku/process/update_certificates.rb
138
+ - lib/letsencrypt_heroku/tools.rb
132
139
  - lib/letsencrypt_heroku/version.rb
133
140
  homepage: https://github.com/xijo/letsencrypt_heroku
134
141
  licenses: []
@@ -1,102 +0,0 @@
1
- require 'open3'
2
-
3
- module LetsencryptHeroku
4
- class Setup
5
- class SetupError < StandardError ; end
6
-
7
- PRODUCTION = 'https://acme-v01.api.letsencrypt.org/'
8
- STAGING = 'https://acme-staging.api.letsencrypt.org/'
9
-
10
- attr_accessor :config
11
-
12
- def initialize(config)
13
- @config = config
14
- @config.endpoint ||= PRODUCTION
15
- @config.domains = config.domains.split
16
- end
17
-
18
- def perform
19
- run_task "preflight" do
20
- # heroku labs:enable http-sni
21
- # heroku plugins:install heroku-certs
22
-
23
- # check that ssl endpoint is on
24
- # check heroku is there
25
- # check that certs are there
26
- end
27
-
28
- run_task 'register with letsencrypt server' do
29
- @private_key = OpenSSL::PKey::RSA.new(4096)
30
- @client = Acme::Client.new(private_key: @private_key, endpoint: config.endpoint)
31
- @client.register(contact: "mailto:#{config.contact}").agree_terms or fail_task('failed resiger')
32
- end
33
-
34
- config.domains.each do |domain|
35
- run_task "authorize #{domain}" do
36
- @challenge = @client.authorize(domain: domain).http01
37
-
38
- execute "heroku config:set LETSENCRYPT_RESPONSE=#{@challenge.file_content}"
39
-
40
- test_response(domain: domain, challenge: @challenge)
41
-
42
- @challenge.request_verification
43
- sleep(1) while 'pending' == @challenge.verify_status
44
- @challenge.verify_status == 'valid' or fail_task("failed authorization")
45
- end
46
- end
47
-
48
- # if has cert: update cert, else add cert
49
-
50
- run_task "update certificates" do
51
- csr = Acme::Client::CertificateRequest.new(names: config.domains)
52
- certificate = @client.new_certificate(csr)
53
- File.write('privkey.pem', certificate.request.private_key.to_pem)
54
- File.write('fullchain.pem', certificate.fullchain_to_pem)
55
-
56
- execute "heroku _certs:update fullchain.pem privkey.pem --confirm #{config.herokuapp}"
57
- FileUtils.rm %w(privkey.pem fullchain.pem)
58
- end
59
- rescue SetupError => e
60
- exit
61
- end
62
-
63
- def test_response(domain:, challenge:)
64
- url = "http://#{domain}/#{challenge.filename}"
65
- fail_count = 0
66
- while fail_count < 30
67
- answer = `curl -sL #{url}`
68
- if answer != challenge.file_content
69
- fail_count += 1
70
- sleep(1)
71
- else
72
- return
73
- end
74
- end
75
- fail_task('failed test response')
76
- end
77
-
78
- def execute(command)
79
- Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
80
- wait_thr.value.success? or fail_task(stderr.read.force_encoding('utf-8').sub(' ▸ ', 'heroku: '))
81
- end
82
- end
83
-
84
- def run_task(name)
85
- @_spinner = TTY::Spinner.new(" :spinner #{name}",
86
- format: :dots,
87
- interval: 20,
88
- frames: [ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" ].map { |s| Rainbow(s).yellow.bright },
89
- success_mark: Rainbow('✔').green,
90
- error_mark: Rainbow('✘').red
91
- )
92
- @_spinner.start
93
- yield
94
- @_spinner.success
95
- end
96
-
97
- def fail_task(reason = nil)
98
- @_spinner.error("(#{reason.strip})")
99
- raise SetupError, reason
100
- end
101
- end
102
- end