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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +305 -0
- data/Rakefile +29 -0
- data/app/assets/config/apartment_acme_client_manifest.js +2 -0
- data/app/assets/javascripts/apartment_acme_client/application.js +13 -0
- data/app/assets/stylesheets/apartment_acme_client/application.css +15 -0
- data/app/controllers/apartment_acme_client/application_controller.rb +5 -0
- data/app/controllers/apartment_acme_client/verifications_controller.rb +5 -0
- data/app/helpers/apartment_acme_client/application_helper.rb +4 -0
- data/app/jobs/apartment_acme_client/application_job.rb +4 -0
- data/app/mailers/apartment_acme_client/application_mailer.rb +6 -0
- data/app/models/apartment_acme_client/application_record.rb +5 -0
- data/app/models/apartment_acme_client/verifier.rb +38 -0
- data/app/views/layouts/apartment_acme_client/application.html.erb +14 -0
- data/config/routes.rb +3 -0
- data/lib/apartment_acme_client.rb +61 -0
- data/lib/apartment_acme_client/acme_client/proxy.rb +14 -0
- data/lib/apartment_acme_client/acme_client/real_client.rb +56 -0
- data/lib/apartment_acme_client/certificate_storage/proxy.rb +15 -0
- data/lib/apartment_acme_client/certificate_storage/s3.rb +74 -0
- data/lib/apartment_acme_client/domain_checker.rb +21 -0
- data/lib/apartment_acme_client/encryption.rb +134 -0
- data/lib/apartment_acme_client/engine.rb +5 -0
- data/lib/apartment_acme_client/file_manipulation/proxy.rb +13 -0
- data/lib/apartment_acme_client/file_manipulation/real.rb +37 -0
- data/lib/apartment_acme_client/nginx_configuration/proxy.rb +13 -0
- data/lib/apartment_acme_client/nginx_configuration/real.rb +128 -0
- data/lib/apartment_acme_client/railtie.rb +8 -0
- data/lib/apartment_acme_client/renewal_service.rb +22 -0
- data/lib/apartment_acme_client/version.rb +3 -0
- data/lib/tasks/encryption.rake +21 -0
- 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,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,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
|