apartment_acme_client 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +305 -0
  4. data/Rakefile +29 -0
  5. data/app/assets/config/apartment_acme_client_manifest.js +2 -0
  6. data/app/assets/javascripts/apartment_acme_client/application.js +13 -0
  7. data/app/assets/stylesheets/apartment_acme_client/application.css +15 -0
  8. data/app/controllers/apartment_acme_client/application_controller.rb +5 -0
  9. data/app/controllers/apartment_acme_client/verifications_controller.rb +5 -0
  10. data/app/helpers/apartment_acme_client/application_helper.rb +4 -0
  11. data/app/jobs/apartment_acme_client/application_job.rb +4 -0
  12. data/app/mailers/apartment_acme_client/application_mailer.rb +6 -0
  13. data/app/models/apartment_acme_client/application_record.rb +5 -0
  14. data/app/models/apartment_acme_client/verifier.rb +38 -0
  15. data/app/views/layouts/apartment_acme_client/application.html.erb +14 -0
  16. data/config/routes.rb +3 -0
  17. data/lib/apartment_acme_client.rb +61 -0
  18. data/lib/apartment_acme_client/acme_client/proxy.rb +14 -0
  19. data/lib/apartment_acme_client/acme_client/real_client.rb +56 -0
  20. data/lib/apartment_acme_client/certificate_storage/proxy.rb +15 -0
  21. data/lib/apartment_acme_client/certificate_storage/s3.rb +74 -0
  22. data/lib/apartment_acme_client/domain_checker.rb +21 -0
  23. data/lib/apartment_acme_client/encryption.rb +134 -0
  24. data/lib/apartment_acme_client/engine.rb +5 -0
  25. data/lib/apartment_acme_client/file_manipulation/proxy.rb +13 -0
  26. data/lib/apartment_acme_client/file_manipulation/real.rb +37 -0
  27. data/lib/apartment_acme_client/nginx_configuration/proxy.rb +13 -0
  28. data/lib/apartment_acme_client/nginx_configuration/real.rb +128 -0
  29. data/lib/apartment_acme_client/railtie.rb +8 -0
  30. data/lib/apartment_acme_client/renewal_service.rb +22 -0
  31. data/lib/apartment_acme_client/version.rb +3 -0
  32. data/lib/tasks/encryption.rake +21 -0
  33. metadata +208 -0
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Apartment acme client</title>
5
+ <%= stylesheet_link_tag "apartment_acme_client/application", media: "all" %>
6
+ <%= javascript_include_tag "apartment_acme_client/application" %>
7
+ <%= csrf_meta_tags %>
8
+ </head>
9
+ <body>
10
+
11
+ <%= yield %>
12
+
13
+ </body>
14
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ ApartmentAcmeClient::Engine.routes.draw do
2
+ get '/verify', to: "verifications#verify"
3
+ end
@@ -0,0 +1,61 @@
1
+ require "apartment_acme_client/version"
2
+ require "apartment_acme_client/domain_checker"
3
+ require "apartment_acme_client/encryption"
4
+ require "apartment_acme_client/renewal_service"
5
+ require "apartment_acme_client/engine"
6
+ require "apartment_acme_client/acme_client/proxy"
7
+ require "apartment_acme_client/acme_client/real_client"
8
+ require "apartment_acme_client/certificate_storage/proxy"
9
+ require "apartment_acme_client/certificate_storage/s3"
10
+ require "apartment_acme_client/nginx_configuration/proxy"
11
+ require "apartment_acme_client/nginx_configuration/real"
12
+ require "apartment_acme_client/file_manipulation/proxy"
13
+ require "apartment_acme_client/file_manipulation/real"
14
+
15
+ require 'apartment_acme_client/railtie' if defined?(Rails)
16
+
17
+ module ApartmentAcmeClient
18
+ # A callback method which lists the possible domains to be checked
19
+ # We will verify each of them before requesting a certificate from Let's Encrypt for all of them
20
+ mattr_accessor :domains_to_check
21
+
22
+ def self.domains_to_check
23
+ if @@domains_to_check.respond_to?(:call)
24
+ @@domains_to_check.call
25
+ else
26
+ @@domains_to_check
27
+ end
28
+ end
29
+
30
+ # The base domain, a domain which is always going to be accessible.
31
+ # because we need a common domain to be used on each request.
32
+ # if not defined, the first 'domain_to_check' which succeeds will be used
33
+ mattr_accessor :common_name
34
+
35
+ # Directory where to store the challenge files, Must be accessible via the internet
36
+ mattr_accessor :public_folder # Rails.root.join('public')
37
+
38
+ # Directory where to store certificates locally
39
+ # must persist between deployments, so that nginx can reference it permanently
40
+ mattr_accessor :certificate_storage_folder # Rails.root.join("public", "system")
41
+
42
+ # for s3 storage
43
+ mattr_accessor :aws_region # Rails.application.secrets.aws_region
44
+ mattr_accessor :aws_bucket # Rails.application.secrets.aws_bucket
45
+
46
+ # For use in the nginx configuration
47
+ mattr_accessor :socket_path # = "/tmp/unicorn-application.socket"
48
+ mattr_accessor :nginx_config_path # "/etc/nginx/conf.d/site.conf"
49
+
50
+ # If your server stops responding 200 OK to http connections (ie: when all connections are forced-ssl)
51
+ # you must verify new subdomains over https instead of http
52
+ mattr_accessor :verify_over_https
53
+
54
+ # For Testing/injection purposes
55
+ mattr_accessor :acme_client_class
56
+ mattr_accessor :certificate_storage_class
57
+ mattr_accessor :file_manipulation_class
58
+ mattr_accessor :nginx_configuration_class
59
+
60
+ mattr_accessor :lets_encrypt_test_server_enabled
61
+ end
@@ -0,0 +1,14 @@
1
+ module ApartmentAcmeClient
2
+ module AcmeClient
3
+ class Proxy
4
+ def self.singleton(options = {})
5
+ base_class.new(options)
6
+ end
7
+
8
+ def self.base_class
9
+ # allow overriding the AcmeClient
10
+ ApartmentAcmeClient.acme_client_class || AcmeClient::RealClient
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,56 @@
1
+ require 'acme-client'
2
+
3
+ module ApartmentAcmeClient
4
+ module AcmeClient
5
+ class RealClient
6
+ def initialize(private_key:)
7
+ @client = Acme::Client.new(
8
+ private_key: private_key,
9
+ endpoint: server_endpoint,
10
+ )
11
+ end
12
+
13
+ def register(email)
14
+ # If the private key is not known to the server, we need to register it for the first time.
15
+ registration = @client.register(contact: "mailto:#{email}")
16
+
17
+ # You may need to agree to the terms of service (that's up the to the server to require it or not but boulder does by default)
18
+ registration.agree_terms
19
+
20
+ true
21
+ end
22
+
23
+ def authorize(domain:)
24
+ @client.authorize(domain: domain)
25
+ end
26
+
27
+ # Create a Certificate for our new set of domain names
28
+ # returns that certificate
29
+ def request_certificate(common_name:, domains:)
30
+ # We're going to need a certificate signing request. If not explicitly
31
+ # specified, the first name listed becomes the common name.
32
+ csr = Acme::Client::CertificateRequest.new(common_name: common_name, names: domains)
33
+
34
+ # We can now request a certificate. You can pass anything that returns
35
+ # a valid DER encoded CSR when calling to_der on it. For example an
36
+ # OpenSSL::X509::Request should work too.
37
+ certificate = @client.new_certificate(csr) # => #<Acme::Client::Certificate ....>
38
+
39
+ certificate
40
+ end
41
+
42
+ private
43
+
44
+ def server_endpoint
45
+ # We need an ACME server to talk to, see github.com/letsencrypt/boulder
46
+ # WARNING: This endpoint is the production endpoint, which is rate limited and will produce valid certificates.
47
+ # You should probably use the staging endpoint for all your experimentation:
48
+ if ApartmentAcmeClient.lets_encrypt_test_server_enabled
49
+ 'https://acme-staging.api.letsencrypt.org/'
50
+ else
51
+ 'https://acme-v01.api.letsencrypt.org/'
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,15 @@
1
+ module ApartmentAcmeClient
2
+ module CertificateStorage
3
+ TEST_PREFIX = "test_".freeze
4
+
5
+ class Proxy
6
+ def self.singleton
7
+ base_class.new
8
+ end
9
+
10
+ def self.base_class
11
+ ApartmentAcmeClient.certificate_storage_class || CertificateStorage::S3
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,74 @@
1
+ require 'aws-sdk-s3'
2
+
3
+ module ApartmentAcmeClient
4
+ module CertificateStorage
5
+ class S3
6
+ def initialize
7
+ @base_prefix = if ApartmentAcmeClient::lets_encrypt_test_server_enabled
8
+ TEST_PREFIX
9
+ else
10
+ ""
11
+ end
12
+ end
13
+
14
+ ENCRYPTION_S3_NAME = 'server_encryption_client_private_key.der'.freeze
15
+
16
+ def store_certificate(certificate)
17
+ # Save the certificate and the private key to files
18
+ File.write(cert_path("privkey.pem"), certificate.request.private_key.to_pem)
19
+ File.write(cert_path("cert.pem"), certificate.to_pem)
20
+ File.write(cert_path("chain.pem"), certificate.chain_to_pem)
21
+ File.write(cert_path("fullchain.pem"), certificate.fullchain_to_pem)
22
+
23
+ store_s3_file(derived_filename("privkey.pem"), certificate.request.private_key.to_pem)
24
+ store_s3_file(derived_filename("cert.pem"), certificate.to_pem)
25
+ store_s3_file(derived_filename("chain.pem"), certificate.chain_to_pem)
26
+ store_s3_file(derived_filename("fullchain.pem"), certificate.fullchain_to_pem)
27
+ end
28
+
29
+ # do we have a certificate on this server?
30
+ # We cannot start nginx when it is pointing at a non-existing certificate,
31
+ # so we need to check
32
+ def cert_exists?
33
+ File.exist?(cert_path("privkey.pem"))
34
+ end
35
+
36
+ def private_key
37
+ s3_object = s3_file(private_key_s3_filename)
38
+ return nil unless s3_object.exists?
39
+
40
+ s3_object.get.body.read
41
+ end
42
+
43
+ # saves a private key to s3
44
+ def save_private_key(private_key)
45
+ store_s3_file(private_key_s3_filename, private_key.to_der)
46
+ end
47
+
48
+ private
49
+
50
+ def private_key_s3_filename
51
+ derived_filename(ENCRYPTION_S3_NAME)
52
+ end
53
+
54
+ def derived_filename(filename)
55
+ "#{@base_prefix}#{filename}"
56
+ end
57
+
58
+ def store_s3_file(filename, file_contents)
59
+ object = s3_file(filename)
60
+ object.put(body: file_contents)
61
+ end
62
+
63
+ def cert_path(filename)
64
+ File.join(ApartmentAcmeClient.certificate_storage_folder, derived_filename(filename))
65
+ end
66
+
67
+ def s3_file(filename)
68
+ s3 = Aws::S3::Resource.new(region: ApartmentAcmeClient.aws_region)
69
+ object = s3.bucket(ApartmentAcmeClient.aws_bucket).object(filename)
70
+ object
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,21 @@
1
+ module ApartmentAcmeClient
2
+ class DomainChecker
3
+ # returns an array containing 2 lists:
4
+ # successful domains
5
+ # rejected domains (those which don't appear properly configured in DNS)
6
+ def accessible_domains
7
+ possible_domains = ApartmentAcmeClient.domains_to_check
8
+
9
+ domains = []
10
+ rejected_domains = []
11
+ possible_domains.each do |domain|
12
+ if ApartmentAcmeClient::Verifier.new(domain).properly_configured?
13
+ domains << domain
14
+ else
15
+ rejected_domains << domain
16
+ end
17
+ end
18
+ [domains, rejected_domains]
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,134 @@
1
+ require 'openssl'
2
+
3
+ # Initially, the system is only accessible via subdomain.example.com
4
+ # But, as we add more Conventions, we want to be able to access those also,
5
+ # thus we will need:
6
+ # - a.subdomain.example.com
7
+ # - b.subdomain.example.com
8
+ # - c.subdomain.example.com
9
+ #
10
+ # Also, each convention may add an "alias" for their convention, like:
11
+ # - www.naucc.com
12
+ # - french-convention.unicycle.fr
13
+ #
14
+ # Steps to make this work:
15
+ # 1) When a new Convention is created, or a new alias is added, configure nginx
16
+ # so that it responds to that domain request
17
+ # `rake update_nginx_config` (writes a new nginx.conf and restarts nginx)
18
+ #
19
+ # 2) Register the new domain with letsencrypt
20
+ # `rake renew_and_update_certificate`
21
+ #
22
+ # Manage the encryption of the website (https).
23
+ module ApartmentAcmeClient
24
+ class Encryption
25
+ def initialize
26
+ @certificate_storage = ApartmentAcmeClient::CertificateStorage::Proxy.singleton
27
+ end
28
+
29
+ # Largely based on https://github.com/unixcharles/acme-client documentation
30
+ def register_new(email)
31
+ raise StandardError.new("Private key already exists") unless @certificate_storage.private_key.nil?
32
+
33
+ private_key = create_private_key
34
+
35
+ # Initialize the client
36
+ new_client = ApartmentAcmeClient::AcmeClient::Proxy.singleton(
37
+ private_key: private_key,
38
+ )
39
+
40
+ new_client.register(email)
41
+
42
+ @certificate_storage.save_private_key(private_key)
43
+ end
44
+
45
+ def authorize_domains(domains)
46
+ successful_domains = domains.select {|domain| authorize_domain(domain) }
47
+ successful_domains
48
+ end
49
+
50
+ # authorizes a domain with letsencrypt server
51
+ # returns true on success, false otherwise.
52
+ #
53
+ # from https://github.com/unixcharles/acme-client/tree/master#authorize-for-domain
54
+ def authorize_domain(domain)
55
+ authorization = client.authorize(domain: domain)
56
+
57
+ # This example is using the http-01 challenge type. Other challenges are dns-01 or tls-sni-01.
58
+ challenge = authorization.http01
59
+
60
+ # The http-01 method will require you to respond to a HTTP request.
61
+
62
+ # You can retrieve the challenge token
63
+ challenge.token # => "some_token"
64
+
65
+ # You can retrieve the expected path for the file.
66
+ challenge.filename # => ".well-known/acme-challenge/:some_token"
67
+
68
+ # You can generate the body of the expected response.
69
+ challenge.file_content # => 'string token and JWK thumbprint'
70
+
71
+ # You are not required to send a Content-Type. This method will return the right Content-Type should you decide to include one.
72
+ challenge.content_type
73
+
74
+ # Save the file. We'll create a public directory to serve it from, and inside it we'll create the challenge file.
75
+ FileUtils.mkdir_p(File.join(ApartmentAcmeClient.public_folder, File.dirname(challenge.filename)))
76
+
77
+ # We'll write the content of the file
78
+ full_challenge_filename = File.join(ApartmentAcmeClient.public_folder, challenge.filename)
79
+ File.write(full_challenge_filename, challenge.file_content)
80
+
81
+ # Optionally save the challenge for use at another time (eg: by a background job processor)
82
+ # File.write('challenge', challenge.to_h.to_json)
83
+
84
+ # The challenge file can be served with a Ruby webserver.
85
+ # You can run a webserver in another console for that purpose. You may need to forward ports on your router.
86
+ #
87
+ # $ ruby -run -e httpd public -p 8080 --bind-address 0.0.0.0
88
+
89
+ # Load a saved challenge. This is only required if you need to reuse a saved challenge as outlined above.
90
+ # challenge = client.challenge_from_hash(JSON.parse(File.read('challenge')))
91
+
92
+ # Once you are ready to serve the confirmation request you can proceed.
93
+ challenge.request_verification # => true
94
+
95
+ success = false
96
+ 10.times do
97
+ if challenge.verify_status == 'valid' # may be 'pending' initially
98
+ success = true
99
+ break
100
+ end
101
+
102
+ # Wait a bit for the server to make the request, or just blink. It should be fast.
103
+ sleep(1)
104
+ end
105
+ File.delete(full_challenge_filename)
106
+
107
+ success
108
+ end
109
+
110
+ def request_certificate(common_name:, domains:)
111
+ client.request_certificate(common_name: common_name, domains: domains)
112
+ end
113
+
114
+ private
115
+
116
+ def client
117
+ @client ||= ApartmentAcmeClient::AcmeClient::Proxy.singleton(
118
+ private_key: private_key,
119
+ )
120
+ end
121
+
122
+ # Returns a private key
123
+ def private_key
124
+ private_key = @certificate_storage.private_key
125
+ return nil unless private_key
126
+
127
+ OpenSSL::PKey::RSA.new(private_key)
128
+ end
129
+
130
+ def create_private_key
131
+ OpenSSL::PKey::RSA.new(4096)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,5 @@
1
+ module ApartmentAcmeClient
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace ApartmentAcmeClient
4
+ end
5
+ end
@@ -0,0 +1,13 @@
1
+ module ApartmentAcmeClient
2
+ module FileManipulation
3
+ class Proxy
4
+ def self.singleton
5
+ base_class.new
6
+ end
7
+
8
+ def self.base_class
9
+ ApartmentAcmeClient.file_manipulation_class || FileManipulation::Real
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,37 @@
1
+ require "open3"
2
+
3
+ module ApartmentAcmeClient
4
+ module FileManipulation
5
+ class Real
6
+ def copy_file(from, to)
7
+ run_command("sudo cp #{from} #{to}")
8
+ end
9
+
10
+ def restart_service(service)
11
+ run_command("sudo service #{service} restart")
12
+ end
13
+
14
+ private
15
+
16
+ def run_command(command)
17
+ Open3.popen3(command) do |_stdin, stdout, stderr, wait_thr|
18
+ stdout_lines = stdout.read
19
+ # puts "stdout is:" + stdout_lines
20
+
21
+ # to watch the output as it runs:
22
+ # while line = stdout.gets
23
+ # puts line
24
+ # end
25
+
26
+ stderr_lines = stderr.read
27
+ # puts "stderr is:" + stderr_lines
28
+ exit_status = wait_thr.value
29
+
30
+ unless exit_status.success?
31
+ abort "FAILED !!! #{command}"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end