apartment_acme_client 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|