apartment_acme_client 0.0.1

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.
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