acme_plugin 0.0.13

Sign up to get free protection for your applications and to get access to all the features.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/Rakefile +33 -0
  4. data/app/controllers/acme_plugin/application_controller.rb +34 -0
  5. data/app/models/acme_plugin/challenge.rb +17 -0
  6. data/app/models/acme_plugin/setting.rb +10 -0
  7. data/config/routes.rb +3 -0
  8. data/db/migrate/20151206135029_create_acme_plugin_challenges.rb +9 -0
  9. data/db/migrate/20160412195212_create_acme_plugin_settings.rb +9 -0
  10. data/lib/acme_plugin.rb +146 -0
  11. data/lib/acme_plugin/certificate_output.rb +17 -0
  12. data/lib/acme_plugin/challenge_store.rb +18 -0
  13. data/lib/acme_plugin/configuration.rb +27 -0
  14. data/lib/acme_plugin/database_store.rb +11 -0
  15. data/lib/acme_plugin/engine.rb +5 -0
  16. data/lib/acme_plugin/file_output.rb +18 -0
  17. data/lib/acme_plugin/file_store.rb +16 -0
  18. data/lib/acme_plugin/heroku_output.rb +19 -0
  19. data/lib/acme_plugin/private_key_store.rb +24 -0
  20. data/lib/acme_plugin/version.rb +5 -0
  21. data/lib/tasks/acme_plugin_tasks.rake +15 -0
  22. data/test/acme_plugin_test.rb +351 -0
  23. data/test/controllers/application_controller_test.rb +26 -0
  24. data/test/dummy/README.rdoc +28 -0
  25. data/test/dummy/Rakefile +6 -0
  26. data/test/dummy/app/assets/javascripts/application.js +13 -0
  27. data/test/dummy/app/assets/stylesheets/application.css +15 -0
  28. data/test/dummy/app/controllers/application_controller.rb +5 -0
  29. data/test/dummy/app/helpers/application_helper.rb +2 -0
  30. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  31. data/test/dummy/bin/bundle +3 -0
  32. data/test/dummy/bin/rails +4 -0
  33. data/test/dummy/bin/rake +4 -0
  34. data/test/dummy/bin/setup +29 -0
  35. data/test/dummy/config.ru +4 -0
  36. data/test/dummy/config/acme_plugin.yml +19 -0
  37. data/test/dummy/config/application.rb +23 -0
  38. data/test/dummy/config/boot.rb +5 -0
  39. data/test/dummy/config/database.yml +25 -0
  40. data/test/dummy/config/environment.rb +5 -0
  41. data/test/dummy/config/environments/development.rb +41 -0
  42. data/test/dummy/config/environments/production.rb +79 -0
  43. data/test/dummy/config/environments/test.rb +45 -0
  44. data/test/dummy/config/initializers/assets.rb +11 -0
  45. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  46. data/test/dummy/config/initializers/cookies_serializer.rb +3 -0
  47. data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  48. data/test/dummy/config/initializers/inflections.rb +16 -0
  49. data/test/dummy/config/initializers/mime_types.rb +4 -0
  50. data/test/dummy/config/initializers/session_store.rb +3 -0
  51. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  52. data/test/dummy/config/locales/en.yml +23 -0
  53. data/test/dummy/config/routes.rb +3 -0
  54. data/test/dummy/config/secrets.yml +22 -0
  55. data/test/dummy/db/schema.rb +27 -0
  56. data/test/dummy/db/test.sqlite3 +0 -0
  57. data/test/dummy/key/test_keyfile_1024.pem +15 -0
  58. data/test/dummy/key/test_keyfile_2048.pem +27 -0
  59. data/test/dummy/key/test_keyfile_4096.pem +51 -0
  60. data/test/dummy/key/test_keyfile_8192.pem +99 -0
  61. data/test/dummy/log/development.log +0 -0
  62. data/test/dummy/log/test.log +25582 -0
  63. data/test/dummy/public/404.html +67 -0
  64. data/test/dummy/public/422.html +67 -0
  65. data/test/dummy/public/500.html +66 -0
  66. data/test/dummy/public/favicon.ico +0 -0
  67. data/test/fixtures/acme_plugin/challenges.yml +7 -0
  68. data/test/fixtures/acme_plugin/settings.yml +56 -0
  69. data/test/lib/acme_plugin/configuration_test.rb +13 -0
  70. data/test/lib/acme_plugin/private_key_store_test.rb +30 -0
  71. data/test/models/acme_plugin/challenge_test.rb +6 -0
  72. data/test/test_helper.rb +23 -0
  73. metadata +309 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6666fcd67062f236c49381267588d414edddd669c4f8520abdff1b918df2b48c
4
+ data.tar.gz: aa9045732686e8149e4fde70f98303f5ba93250af488904b9ab3ca8e007a7314
5
+ SHA512:
6
+ metadata.gz: 2ecad8e88f8138d7ef00129396d8ea4908fe89dbb1831378124c856309b4d7fba8016a313acbc92663fd928aa40d749c065a60e8dc87edfd276e6bbc90ca9610
7
+ data.tar.gz: db3cfe8b2ce24e2f56bfa6f11d91acd42cf9017c6d5d13423a28ddc4d4cd58fd12c92b4f50c1cb7fc34b20455476c749ba7887cdc6e2d2cf2384779ef2571101
@@ -0,0 +1,20 @@
1
+ Copyright 2015-2018 Lukasz Gromanowski <lgromanowski@gmail.com>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,33 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'AcmePlugin'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.rdoc')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ APP_RAKEFILE = File.expand_path('../test/dummy/Rakefile', __FILE__)
18
+ load 'rails/tasks/engine.rake'
19
+
20
+ load 'rails/tasks/statistics.rake'
21
+
22
+ Bundler::GemHelper.install_tasks
23
+
24
+ require 'rake/testtask'
25
+
26
+ Rake::TestTask.new(:test) do |t|
27
+ t.libs << 'lib'
28
+ t.libs << 'test'
29
+ t.pattern = 'test/**/*_test.rb'
30
+ t.verbose = false
31
+ end
32
+
33
+ task default: :test
@@ -0,0 +1,34 @@
1
+ module AcmePlugin
2
+ class ApplicationController < ActionController::Base
3
+ before_action :challenge_response, only: [:index]
4
+ before_action :validate_length, only: [:index]
5
+ protect_from_forgery with: :exception
6
+
7
+ def index
8
+ # There is only one item in DB with challenge response from our task
9
+ # we will use it to render plain text response
10
+ render plain: @response.response, status: :ok
11
+ end
12
+
13
+ private
14
+
15
+ def validate_length
16
+ # Challenge request should have at least 128bit
17
+ challenge_failed('Challenge failed - Request has invalid length!') if invalid_length
18
+ end
19
+
20
+ def challenge_response
21
+ @response = AcmePlugin.config.challenge_dir_name.present? ? Challenge.new : Challenge.first
22
+ challenge_failed('Challenge failed - Can not get response from database!') if @response.nil?
23
+ end
24
+
25
+ def challenge_failed(msg)
26
+ Rails.logger.error(msg)
27
+ render plain: msg, status: :bad_request
28
+ end
29
+
30
+ def invalid_length
31
+ params[:challenge].nil? || params[:challenge].length < 16 || params[:challenge].length > 256
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ module AcmePlugin
2
+ # if the project doesn't use ActiveRecord, we assume the challenge will
3
+ # be stored in the filesystem
4
+ if AcmePlugin.config.challenge_dir_name.blank? && defined?(ActiveRecord::Base) == 'constant' && ActiveRecord::Base.class == Class
5
+ class Challenge < ActiveRecord::Base
6
+ end
7
+ else
8
+ class Challenge
9
+ attr_accessor :response
10
+
11
+ def initialize
12
+ full_challenge_dir = File.join(Rails.root, AcmePlugin.config.challenge_dir_name, 'challenge')
13
+ @response = IO.read(full_challenge_dir)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,10 @@
1
+ module AcmePlugin
2
+ if AcmePlugin.config.challenge_dir_name.blank? && defined?(ActiveRecord::Base) == 'constant' && ActiveRecord::Base.class == Class
3
+ class Setting < ActiveRecord::Base
4
+ end
5
+ else
6
+ class Setting
7
+ attr_accessor :private_key
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,3 @@
1
+ AcmePlugin::Engine.routes.draw do
2
+ get '.well-known/acme-challenge/:challenge' => 'application#index'
3
+ end
@@ -0,0 +1,9 @@
1
+ class CreateAcmePluginChallenges < ActiveRecord::Migration[4.2]
2
+ def change
3
+ create_table :acme_plugin_challenges do |t|
4
+ t.text :response
5
+
6
+ t.timestamps null: false
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ class CreateAcmePluginSettings < ActiveRecord::Migration[4.2]
2
+ def change
3
+ create_table :acme_plugin_settings do |t|
4
+ t.text :private_key
5
+
6
+ t.timestamps null: false
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,146 @@
1
+ require 'acme_plugin/engine'
2
+ require 'acme_plugin/file_output'
3
+ require 'acme_plugin/heroku_output'
4
+ require 'acme_plugin/file_store'
5
+ require 'acme_plugin/database_store'
6
+ require 'acme_plugin/configuration'
7
+ require 'acme_plugin/private_key_store'
8
+ require 'acme-client'
9
+
10
+ module AcmePlugin
11
+ def self.config
12
+ # load files on demand
13
+ @config ||= Configuration.load_file
14
+ end
15
+
16
+ def self.config=(config)
17
+ @config = config
18
+ end
19
+
20
+ class CertGenerator
21
+ attr_reader :options, :cert, :client
22
+
23
+ def initialize(options = {})
24
+ @options = options
25
+ @options.freeze
26
+ end
27
+
28
+ def generate_certificate
29
+ register
30
+ domains = @options[:domain].split(' ')
31
+ return unless authorize_and_handle_challenge(domains)
32
+ # We can now request a certificate
33
+ Rails.logger.info('Creating CSR...')
34
+ @cert = @client.new_certificate(Acme::Client::CertificateRequest.new(names: domains))
35
+ save_certificate(@cert)
36
+ Rails.logger.info('Certificate has been generated.')
37
+ end
38
+
39
+ def authorize_and_handle_challenge(domains)
40
+ result = false
41
+ domains.each do |domain|
42
+ authorize(domain)
43
+ handle_challenge
44
+ request_challenge_verification
45
+ result = valid_verification_status
46
+ break unless result
47
+ end
48
+ result
49
+ end
50
+
51
+ def client
52
+ @client ||= Acme::Client.new(private_key: private_key, endpoint: @options[:endpoint])
53
+ end
54
+
55
+ def private_key
56
+ store ||= PrivateKeyStore.new(private_key_from_db) if @options.fetch(:private_key_in_db, false)
57
+
58
+ pk_id = @options.fetch(:private_key, nil)
59
+
60
+ raise 'Private key is not set, please check your config/acme_plugin.yml file!' if pk_id.nil? || pk_id.empty?
61
+
62
+ store ||= PrivateKeyStore.new(private_key_from_file(private_key_path(pk_id))) if File.file?(private_key_path(pk_id))
63
+
64
+ raise "Can not open private key: #{private_key_path(pk_id)}" if File.directory?(private_key_path(pk_id))
65
+
66
+ store ||= PrivateKeyStore.new(pk_id)
67
+ store.retrieve
68
+ end
69
+
70
+ def private_key_path(private_key_file)
71
+ Rails.root.join(private_key_file)
72
+ end
73
+
74
+ def private_key_from_db
75
+ settings = AcmePlugin::Setting.first
76
+ raise 'Empty private_key field in settings table!' if settings.private_key.nil?
77
+ settings.private_key
78
+ end
79
+
80
+ def private_key_from_file(filepath)
81
+ File.read(filepath)
82
+ end
83
+
84
+ def register
85
+ Rails.logger.info('Trying to register at Let\'s Encrypt service...')
86
+ registration = client.register(contact: "mailto:#{@options[:email]}")
87
+ registration.agree_terms
88
+ Rails.logger.info('Registration succeed.')
89
+ rescue => e
90
+ Rails.logger.info("#{e.class} - #{e.message}. Already registered.")
91
+ end
92
+
93
+ def common_domain_name
94
+ @domain ||= @options[:cert_name] || @options[:domain].split(' ').first.to_s
95
+ end
96
+
97
+ def authorize(domain = common_domain_name)
98
+ Rails.logger.info("Sending authorization request for: #{domain}...")
99
+ @authorization = client.authorize(domain: domain)
100
+ end
101
+
102
+ def store_challenge(challenge)
103
+ if @options[:challenge_dir_name].nil? || @options[:challenge_dir_name].empty?
104
+ DatabaseStore.new(challenge.file_content).store
105
+ else
106
+ FileStore.new(challenge.file_content, @options[:challenge_dir_name]).store
107
+ end
108
+ sleep(2)
109
+ end
110
+
111
+ def handle_challenge
112
+ @challenge = @authorization.http01
113
+ store_challenge(@challenge)
114
+ end
115
+
116
+ def request_challenge_verification
117
+ @challenge.request_verification
118
+ end
119
+
120
+ def wait_for_status(challenge)
121
+ Rails.logger.info('Waiting for challenge status...')
122
+ counter = 0
123
+ while challenge.verify_status == 'pending' && counter < 10
124
+ sleep(1)
125
+ counter += 1
126
+ end
127
+ end
128
+
129
+ def valid_verification_status
130
+ wait_for_status(@challenge)
131
+ return true if @challenge.verify_status == 'valid'
132
+ Rails.logger.error('Challenge verification failed! ' \
133
+ "Error: #{@challenge.error['type']}: #{@challenge.error['detail']}")
134
+ false
135
+ end
136
+
137
+ # Save the certificate and key
138
+ def save_certificate(certificate)
139
+ return unless certificate
140
+ return HerokuOutput.new(common_domain_name, certificate).output unless ENV['DYNO'].nil?
141
+ output_dir = File.join(Rails.root, @options[:output_cert_dir])
142
+ return FileOutput.new(common_domain_name, certificate, output_dir).output if File.directory?(output_dir)
143
+ Rails.logger.error("Output directory: '#{output_dir}' does not exist!")
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,17 @@
1
+ module AcmePlugin
2
+ class CertificateOutput
3
+ def initialize(domain, cert)
4
+ @certificate = cert
5
+ @domain = domain
6
+ end
7
+
8
+ def output
9
+ display_info
10
+
11
+ output_cert('cert.pem', @certificate.to_pem)
12
+ output_cert('key.pem', @certificate.request.private_key.to_pem)
13
+ output_cert('chain.pem', @certificate.chain_to_pem)
14
+ output_cert('fullchain.pem', @certificate.fullchain_to_pem)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ module AcmePlugin
2
+ class ChallengeStore
3
+ def initialize(challenge_content)
4
+ @content = challenge_content
5
+ end
6
+
7
+ def store
8
+ display_info
9
+ store_content
10
+ end
11
+
12
+ protected
13
+
14
+ def display_info
15
+ Rails.logger.info('Storing challenge information...')
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ module AcmePlugin
2
+ Config = Class.new(OpenStruct)
3
+
4
+ # This is a class whose responsibility is to load the lets_encrypt configuration file
5
+ module Configuration
6
+ def self.load_file(filename = Rails.root.join('config', 'acme_plugin.yml'))
7
+ config_data = parse_yaml_file(filename)
8
+ create_config(config_data)
9
+ end
10
+
11
+ def self.create_config(config_hash)
12
+ Config.new(config_hash.merge(config_hash.fetch(Rails.env, {})) || {})
13
+ end
14
+
15
+ def self.read_file(filename)
16
+ File.read(filename)
17
+ end
18
+
19
+ def self.evaluate_file(filename)
20
+ ERB.new(read_file(filename)).result
21
+ end
22
+
23
+ def self.parse_yaml_file(filename)
24
+ YAML.load(evaluate_file(filename))
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ require 'acme_plugin/challenge_store'
2
+
3
+ module AcmePlugin
4
+ class DatabaseStore < ChallengeStore
5
+ def store_content
6
+ ch = AcmePlugin::Challenge.first
7
+ ch = AcmePlugin::Challenge.new if ch.nil?
8
+ ch.update(response: @content)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ module AcmePlugin
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace AcmePlugin
4
+ end
5
+ end
@@ -0,0 +1,18 @@
1
+ require 'acme_plugin/certificate_output'
2
+
3
+ module AcmePlugin
4
+ class FileOutput < CertificateOutput
5
+ def initialize(domain, cert, out_dir)
6
+ super(domain, cert)
7
+ @output_dir = out_dir
8
+ end
9
+
10
+ def output_cert(cert_type, cert_content)
11
+ File.write(File.join(@output_dir, "#{@domain}-#{cert_type}"), cert_content)
12
+ end
13
+
14
+ def display_info
15
+ Rails.logger.info('Saving certificates and key...')
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ require 'acme_plugin/challenge_store'
2
+
3
+ module AcmePlugin
4
+ class FileStore < ChallengeStore
5
+ def initialize(challenge_content, challenge_dir_name)
6
+ super(challenge_content)
7
+ @challenge_dir_name = challenge_dir_name
8
+ end
9
+
10
+ def store_content
11
+ full_challenge_dir = File.join(Rails.root, @challenge_dir_name)
12
+ Dir.mkdir(full_challenge_dir) unless File.directory?(full_challenge_dir)
13
+ File.open(File.join(full_challenge_dir, 'challenge'), 'w') { |file| file.write(@content) }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,19 @@
1
+ require 'acme_plugin/certificate_output'
2
+
3
+ module AcmePlugin
4
+ class HerokuOutput < CertificateOutput
5
+ def initialize(domain, cert)
6
+ super(domain, cert)
7
+ end
8
+
9
+ def output_cert(cert_type, cert_content)
10
+ Rails.logger.info("====== #{@domain}-#{cert_type} ======")
11
+ puts cert_content
12
+ end
13
+
14
+ def display_info
15
+ Rails.logger.info('You are running this script on Heroku, please copy-paste certificates to your local machine')
16
+ Rails.logger.info('and then follow https://devcenter.heroku.com/articles/ssl-endpoint guide:')
17
+ end
18
+ end
19
+ end