puma-acme 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a03fafd8bedea51ad2a908d71c255510bc2cd852d0f5587916e3bce12635c58d
4
+ data.tar.gz: 146ab48df5ba65410b00fb065d891373076814efb47bfc184e94f164b6b75f6b
5
+ SHA512:
6
+ metadata.gz: 746645713f39db8403b0299ee0a473b6b54c750fdd8d4f9a371a00894c02b35fb96367c22195e1707beea93a0ef65939235c0f6cddb4f0be43606546957db440
7
+ data.tar.gz: 74f1b2504eeb5dc97fe6ce421c389989cfd6ce5a5783daeb911d556b0fac915292d9d4ce6710ef81b027901f75426e0aecff3a86fbf58927dc99529d9e18a11a
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## 0.1.0 - 12/29/23
4
+
5
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,58 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ puma-acme (0.1.0)
5
+ acme-client (~> 2.0.13)
6
+ pstore (~> 0.1)
7
+ puma (~> 6.4)
8
+ sinatra (~> 3.1)
9
+
10
+ GEM
11
+ remote: https://rubygems.org/
12
+ specs:
13
+ acme-client (2.0.15)
14
+ faraday (>= 1.0, < 3.0.0)
15
+ faraday-retry (>= 1.0, < 3.0.0)
16
+ base64 (0.2.0)
17
+ faraday (2.7.12)
18
+ base64
19
+ faraday-net_http (>= 2.0, < 3.1)
20
+ ruby2_keywords (>= 0.0.4)
21
+ faraday-net_http (3.0.2)
22
+ faraday-retry (2.2.0)
23
+ faraday (~> 2.0)
24
+ http.rb (0.12.0)
25
+ minitest (5.20.0)
26
+ minitest-mock_expectations (1.2.0)
27
+ mustermann (3.0.0)
28
+ ruby2_keywords (~> 0.0.1)
29
+ nio4r (2.7.0)
30
+ pstore (0.1.3)
31
+ puma (6.4.0)
32
+ nio4r (~> 2.0)
33
+ rack (2.2.8)
34
+ rack-protection (3.1.0)
35
+ rack (~> 2.2, >= 2.2.4)
36
+ rake (13.1.0)
37
+ ruby2_keywords (0.0.5)
38
+ sinatra (3.1.0)
39
+ mustermann (~> 3.0)
40
+ rack (~> 2.2, >= 2.2.4)
41
+ rack-protection (= 3.1.0)
42
+ tilt (~> 2.0)
43
+ tilt (2.3.0)
44
+ vcr (6.2.0)
45
+
46
+ PLATFORMS
47
+ arm64-darwin-23
48
+
49
+ DEPENDENCIES
50
+ http.rb (~> 0.12)
51
+ minitest (~> 5.14)
52
+ minitest-mock_expectations (~> 1.2)
53
+ puma-acme!
54
+ rake (~> 13.0)
55
+ vcr (~> 6.1)
56
+
57
+ BUNDLED WITH
58
+ 2.3.26
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Anchor Security, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # puma-acme
2
+
3
+ A [Puma](https://puma.io/) plugin for for SSL certs via
4
+ [ACME](https://www.rfc-editor.org/rfc/rfc8555.html).
5
+
6
+ Easily add HTTPS support to a Puma server. To use, configure the server
7
+ name(s), accept the TOS, and add a bind directive. The plugin will handle ACME
8
+ account creation, order handling, challenge responses, and certificate
9
+ provisioning. A rack handler for
10
+ [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge)
11
+ is added to the app so that the non-SSL address can automatically answer
12
+ challenges.
13
+
14
+ On first boot, the server will boot without the SSL listener, and run the ACME
15
+ order workflow in a background thread of the main process. When the workflow
16
+ finishes and the certificate is ready, the server performs a graceful restart
17
+ and binds to the SSL port.
18
+
19
+ If the configured cache contains an unexpired certificate that matches the
20
+ algorithm and server name(s), it will use the cert to bind a listener at server
21
+ boot.
22
+
23
+ Automatic renewals can be enabled by configuring a renewal interval based on
24
+ the remaining time as a duration or a fraction. See the `acme_renew_at`
25
+ directive for details.
26
+
27
+ Supports standalone and cluster mode, along with graceful & rolling restarts.
28
+
29
+ ## Installation
30
+
31
+ `Gemfile`:
32
+
33
+ ```ruby
34
+ gem 'puma-acme'
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ basic:
40
+
41
+ ```ruby
42
+ # config/puma.rb
43
+
44
+ config.bind 'tcp://0.0.0.0:80'
45
+
46
+ plugin :acme
47
+
48
+ acme_server_name 'puma-acme.example.org'
49
+ acme_tos_agreed true
50
+
51
+ config.bind 'acme://0.0.0.0:443'
52
+ ```
53
+
54
+ advanced:
55
+
56
+ ```ruby
57
+ # config/puma.rb
58
+
59
+ # Account contact URL(s). For email, use the form 'mailto:user@domain.tld'.
60
+ # Recommended for account recovery and revocation.
61
+ acme_contact 'mailto:acme-user@exmaple.org'
62
+
63
+ # Specify multiple server names (SAN extension).
64
+ acme_server_names 'puma-acme.example.org', 'www.puma-acme.example.org'
65
+
66
+ # Enable automatic renewal based on an amount of time or fraction of life
67
+ # remaining. For an amount of time, use a number of seconds as an Integer, for
68
+ # example the value 2592000 will set renewal to 30 days before certificate
69
+ # expiration. Use a Float between 0 and 1 for fraction of life remaining, for
70
+ # example the value 0.75 will set renewal at %75 of the way through the
71
+ # certificate lifetime.
72
+ acme_renew_at 0.75
73
+
74
+ # URL of ACME server's directory URL, default is LetsEncrypt.
75
+ acme_directory 'https://acme.example.org/directory'
76
+
77
+ # Accept the Terms of Service (TOS) of an ACME server, the value can be either
78
+ # the server's directory URL as a string, or true to accept any server's TOS.
79
+ acme_tos_agreed 'https://acme.example.org/directory'
80
+
81
+ # External Account Binding (EAB) token KID & secret HMAC key for the ACME
82
+ # server. See RFC 8555, Section 7.3.4 for details.
83
+ acme_eab_kid ENV['ACME_KID']
84
+ acme_eab_hmac_key ENV['ACME_HMAC_KEY']
85
+
86
+ # Encryption key algorithm, :ecdsa & :rsa are supported, :ecdsa is the default.
87
+ acme_algorithm :ecdsa
88
+
89
+ # Provision mode, :background and :foreground are supported, :background is the
90
+ # default. Background mode will provision certificates in a background thread
91
+ # without blocking binding or request serving for non-acme listeners.
92
+ # Foreground mode blocks all binding listeners until a certificate is
93
+ # provisioned, and is only compatable with zero-challenge ACME flow.
94
+ acme_mode :background
95
+
96
+ # Store account, order, and certificate data in any ActiveSupport::Cache::Store
97
+ # compatable cache store. A local filesystem based cache in the default.
98
+ acme_cache Rails.cache
99
+
100
+ # Path to the cache directory for the default cache, 'tmp/acme' is the default.
101
+ acme_cache_dir 'tmp/acme'
102
+
103
+ # Time to wait in seconds before checking for an updated order status, 1 second
104
+ # is the default.
105
+ acme_poll_interval 1
106
+
107
+ # Time to wait in seconds before checking for automatic renewal, default is 1
108
+ # hour.
109
+ acme_renew_interval 60 * 60
110
+ ```
111
+
112
+ ## Tested ACME Providers
113
+
114
+ * [Anchor](https://anchor.dev/)
115
+ * [Let's Encrypt](https://letsencrypt.org/)
116
+ * [ZeroSSL](https://zerossl.com/)
117
+
118
+ ## License
119
+
120
+ The gem is available as open source under the terms of the [MIT
121
+ License](http://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << 'test'
7
+ t.libs << 'lib'
8
+ t.test_files = FileList['test/**/*_test.rb']
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ # Extend the ::Puma::Binder class to add hooks for acme binding support.
5
+ class Binder
6
+ def parse_with_before_hooks(*a, &b)
7
+ @before_parse_hooks&.each(&:call)
8
+
9
+ original_parse(*a, &b)
10
+ end
11
+
12
+ alias original_parse parse
13
+ alias parse parse_with_before_hooks
14
+
15
+ def before_parse_hook(&hook)
16
+ (@before_parse_hooks ||= []) << hook
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ module Acme
5
+ # DiskStore is a simple key/value store that persists to disk using PStore.
6
+ class DiskStore
7
+ def initialize(dir)
8
+ path = File.join(dir, 'puma-acme.pstore')
9
+ @pstore = PStore.new(path, true)
10
+ end
11
+
12
+ def delete(key, _options = nil)
13
+ @pstore.transaction do
14
+ !!@pstore.delete(key)
15
+ end
16
+ end
17
+
18
+ def fetch(key, _options = nil, &block)
19
+ raise ArgumentError if block.nil?
20
+
21
+ @pstore.transaction do
22
+ @pstore[key] || (@pstore[key] = yield(key))
23
+ end
24
+ end
25
+
26
+ def read(key, _options = nil)
27
+ @pstore.transaction(true) do
28
+ @pstore[key]
29
+ end
30
+ end
31
+
32
+ def write(key, value, _options = nil)
33
+ @pstore.transaction do
34
+ @pstore[key] = value
35
+ true
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ # Extend the ::Puma::DSL module with the configuration options we want
5
+ class DSL
6
+ def acme_algorithm(algo = nil)
7
+ @options[:acme_algorithm] = algo if algo
8
+ @options[:acme_algorithm]
9
+ end
10
+
11
+ def acme_cache(cache = nil)
12
+ @options[:acme_cache] = cache if cache
13
+ @options[:acme_cache]
14
+ end
15
+
16
+ def acme_cache_dir(dir = nil)
17
+ @options[:acme_cache_dir] = dir if dir
18
+ @options[:acme_cache_dir]
19
+ end
20
+
21
+ def acme_contact(contact = nil)
22
+ @options[:acme_contact] = contact if contact
23
+ @options[:acme_contact]
24
+ end
25
+
26
+ def acme_directory(url = nil)
27
+ @options[:acme_directory] = url if url
28
+ @options[:acme_directory]
29
+ end
30
+
31
+ def acme_eab_kid(kid = nil)
32
+ @options[:acme_eab_kid] = kid if kid
33
+ @options[:acme_eab_kid]
34
+ end
35
+
36
+ def acme_eab_hmac_key(hmac_key = nil)
37
+ @options[:acme_eab_hmac_key] = hmac_key if hmac_key
38
+ @options[:acme_eab_hmac_key]
39
+ end
40
+
41
+ def acme_mode(mode = nil)
42
+ @options[:acme_mode] = mode if mode
43
+ @options[:acme_mode]
44
+ end
45
+
46
+ def acme_poll_interval(interval = nil)
47
+ @options[:acme_poll_interval] = interval if interval
48
+ @options[:acme_poll_interval]
49
+ end
50
+
51
+ def acme_renew_at(duration = nil)
52
+ @options[:acme_renew_at] = duration if duration
53
+ @options[:acme_renew_at]
54
+ end
55
+
56
+ def acme_renew_interval(duration = nil)
57
+ @options[:acme_renew_interval] = duration if duration
58
+ @options[:acme_renew_interval]
59
+ end
60
+
61
+ def acme_server_name(name)
62
+ (@options[:acme_server_names] ||= []) << name
63
+ end
64
+
65
+ def acme_server_names(*names)
66
+ (@options[:acme_server_names] ||= []).unshift(*names)
67
+ @options[:acme_server_names]
68
+ end
69
+
70
+ def acme_tos_agreed(agreed)
71
+ (@options[:acme_tos_agreed] = agreed) unless agreed.nil?
72
+ @options[:acme_to_agreed]
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ module Acme
5
+ # Manager tracks and performs the ACME workflow steps for a certificate.
6
+ class Manager
7
+ attr_reader :contact, :directory, :tos_agreed, :eab
8
+
9
+ def initialize(store:, directory:, tos_agreed:, eab:, contact: nil)
10
+ @store = store
11
+ @contact = contact
12
+ @directory = directory
13
+ @tos_agreed = [true, directory].include?(tos_agreed)
14
+ @eab = eab
15
+ end
16
+
17
+ def account
18
+ @store.read(Account.key(directory: directory, contact: contact, eab: eab))
19
+ end
20
+
21
+ def cert(algorithm:, identifiers:)
22
+ @store.read(Cert.key(algorithm: algorithm, identifiers: identifiers))
23
+ end
24
+
25
+ def answer(type:, token:)
26
+ @store.read(Answer.key(type: type, token: token))
27
+ end
28
+
29
+ def account!
30
+ @store.fetch(Account.key(directory: directory, contact: contact, eab: eab)) { create_account }
31
+ end
32
+
33
+ def cert!(algorithm:, identifiers:)
34
+ @store.fetch(Cert.key(algorithm: algorithm, identifiers: identifiers)) { Cert.new(algorithm: algorithm, identifiers: identifiers) }
35
+ end
36
+
37
+ def order!(cert)
38
+ stale_check!(cert)
39
+
40
+ identifiers = cert.identifiers.map(&:value)
41
+ acme_order = client.new_order(**cert.to_h.slice(:not_before, :not_after).merge(identifiers: identifiers))
42
+ cert.order = Order.from(acme_order)
43
+
44
+ # TODO: maybe move this to caller
45
+ cert.order.authorizations.each do |authz|
46
+ authz.challenges.each do |challenge|
47
+ next unless challenge.type == CHALLENGE_TYPE
48
+
49
+ validate!(challenge)
50
+ end
51
+ end
52
+
53
+ @store.write(cert.key, cert) && cert.order
54
+ end
55
+
56
+ def validate!(challenge)
57
+ @store.write(challenge.answer.key, challenge.answer)
58
+
59
+ client.request_challenge_validation(url: challenge.url)
60
+ end
61
+
62
+ def finalize!(cert)
63
+ stale_check!(cert)
64
+
65
+ names = cert.identifiers.map(&:value)
66
+ common_name = names.first
67
+ private_key = new_key(cert.algorithm)
68
+
69
+ csr = ::Acme::Client::CertificateRequest.new(common_name: common_name, names: names, private_key: private_key)
70
+
71
+ acme_order = client.order(url: cert.order.url)
72
+ return unless acme_order.finalize(csr: csr)
73
+
74
+ cert.order = Order.from(acme_order)
75
+ cert.key_pem = private_key.to_pem
76
+
77
+ @store.write(cert.key, cert) && cert
78
+ end
79
+
80
+ def download!(cert)
81
+ stale_check!(cert)
82
+
83
+ acme_order = client.order(url: cert.order.url)
84
+
85
+ cert.cert_pem = acme_order.certificate
86
+
87
+ @store.write(cert.key, cert) && cert
88
+ end
89
+
90
+ def reload!(cert)
91
+ stale_check!(cert)
92
+
93
+ acme_order = client.order(url: cert.order.url)
94
+ cert.order = Order.from(acme_order)
95
+
96
+ @store.write(cert.key, cert) && cert
97
+ end
98
+
99
+ protected
100
+
101
+ def client
102
+ @client ||= acme_client
103
+ end
104
+
105
+ def acme_client
106
+ account = self.account!
107
+
108
+ ::Acme::Client.new(
109
+ kid: account.kid,
110
+ private_key: load_key(account.jwk, account.key_pem),
111
+ directory: directory
112
+ )
113
+ end
114
+
115
+ def create_account
116
+ private_key = new_key(:ecdsa)
117
+
118
+ client = ::Acme::Client.new(
119
+ private_key: private_key,
120
+ directory: directory
121
+ )
122
+
123
+ acme_account = client.new_account(
124
+ contact: contact,
125
+ terms_of_service_agreed: tos_agreed,
126
+ external_account_binding: eab&.to_h
127
+ )
128
+
129
+ account_parts = {
130
+ jwk: client.jwk.to_h,
131
+ kid: client.kid,
132
+ key_pem: private_key.to_pem,
133
+ tos_agreed: tos_agreed
134
+ }
135
+
136
+ Account.new(acme_account.to_h.slice(:url, :status, :contact).merge(account_parts))
137
+ end
138
+
139
+ def new_key(algorithm)
140
+ case algorithm.to_sym
141
+ when :ecdsa then OpenSSL::PKey::EC.generate('prime256v1')
142
+ when :rsa then OpenSSL::PKey::RSA.new(2048)
143
+ else raise UnknownAlgorithmError, "unknown key algorithm '#{algorithm}'"
144
+ end
145
+ end
146
+
147
+ def load_key(jwk, data)
148
+ case jwk.slice(:kty, :crv)
149
+ when { kty: 'RSA' } then OpenSSL::PKey::RSA.new(data)
150
+ when { kty: 'EC', crv: 'P-256' } then OpenSSL::PKey::EC.new(data)
151
+ else raise UnknownAlgorithmError, "unknown key algorithm '#{jwk[:kty]}'"
152
+ end
153
+ end
154
+
155
+ def stale_check!(cert)
156
+ raise StaleCert if @store.read(cert.key) != cert
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ module Acme
5
+ # ACME challenge response handler for HTTP-01 challenges.
6
+ class Middleware < Sinatra::Base
7
+ def initialize(app, manager:)
8
+ @app = app
9
+ @manager = manager
10
+
11
+ super(app)
12
+ end
13
+
14
+ get '/.well-known/acme-challenge/:token' do
15
+ if (token = params[:token]).nil?
16
+ return @app.call(env)
17
+ end
18
+
19
+ if (answer = @manager.answer(type: CHALLENGE_TYPE, token: token)).nil?
20
+ return @app.call(env)
21
+ end
22
+
23
+ content_type 'text/plain'
24
+ answer.value
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ module Acme
5
+ # Puma plugin for SSL certificate provisioning via an ACME server.
6
+ class Plugin < Puma::Plugin
7
+ Plugins.register('acme', self)
8
+
9
+ def start(launcher)
10
+ identifiers = launcher.options[:acme_server_names] || raise(Error, 'missing ACME server name(s)')
11
+ algorithm = launcher.options.fetch(:acme_algorithm, :ecdsa)
12
+
13
+ contact = launcher.options.fetch(:acme_contact, nil)
14
+ directory = launcher.options.fetch(:acme_directory, DEFAULT_DIRECTORY)
15
+ tos_agreed = launcher.options.fetch(:acme_tos_agreed, false)
16
+
17
+ if (eab_kid = launcher.options[:acme_eab_kid])
18
+ eab = Eab.new(kid: eab_kid, hmac_key: launcher.options.fetch(:acme_eab_hmac_key))
19
+ end
20
+
21
+ store = launcher.options[:acme_cache] || disk_store(launcher.options)
22
+
23
+ mode = launcher.options.fetch(:acme_mode, :background)
24
+ poll_interval = launcher.options.fetch(:acme_poll_interval, 1)
25
+ renew_at = launcher.options.fetch(:acme_renew_at, nil)
26
+ renew_interval = launcher.options.fetch(:acme_renew_interval, DEFAULT_RENEW_INTERVAL)
27
+
28
+ @manager = Manager.new(
29
+ store: store,
30
+ contact: contact,
31
+ directory: directory,
32
+ tos_agreed: tos_agreed,
33
+ eab: eab
34
+ )
35
+
36
+ @acme_binds, binds = launcher.options[:binds].partition { |bind| bind.start_with?('acme://') }
37
+ launcher.options[:binds] = binds
38
+
39
+ launcher.options[:app] = Middleware.new(
40
+ launcher.options[:app],
41
+ manager: @manager
42
+ )
43
+
44
+ @log_writer = launcher.log_writer
45
+
46
+ cert = @manager.cert!(identifiers: identifiers, algorithm: algorithm)
47
+ if cert.usable?
48
+ @log_writer.debug 'Acme: cert already provisioned'
49
+
50
+ bind_to(launcher, cert)
51
+
52
+ if renew_at
53
+ in_background do
54
+ renew(cert, renew_at: renew_at, renew_interval: renew_interval, poll_interval: poll_interval)
55
+
56
+ launcher.restart
57
+ end
58
+ end
59
+ elsif mode == :background
60
+ @log_writer.log 'Puma background provisioning cert via puma-acme plugin...'
61
+
62
+ in_background do
63
+ provision(cert, poll_interval: poll_interval)
64
+
65
+ @log_writer.log 'Puma restarting after provisioning cert via puma-acme plugin...'
66
+
67
+ launcher.restart
68
+ end
69
+ elsif mode == :foreground
70
+ @log_writer.log 'Puma foreground provisioning cert via puma-acme plugin...'
71
+
72
+ provision(cert, poll_interval: poll_interval)
73
+ bind_to(launcher, cert)
74
+
75
+ if renew_at
76
+ in_background do
77
+ renew(cert, renew_at: renew_at, renew_interval: renew_interval, poll_interval: poll_interval)
78
+
79
+ launcher.restart
80
+ end
81
+ end
82
+ else
83
+ raise UnknownMode, mode
84
+ end
85
+ end
86
+
87
+ protected
88
+
89
+ def bind_to(launcher, cert)
90
+ params = {
91
+ 'key_pem' => cert.key_pem,
92
+ 'cert_pem' => cert.cert_pem
93
+ }
94
+
95
+ ctx = MiniSSL::ContextBuilder.new(params, @log_writer).context
96
+
97
+ launcher.binder.before_parse_hook do
98
+ @acme_binds.each do |str|
99
+ uri = URI.parse(str)
100
+
101
+ if (fd = launcher.binder.inherited_fds.delete(str))
102
+ io = launcher.binder.inherit_ssl_listener(fd, ctx)
103
+ @log_writer.log "* Inherited #{str}"
104
+ elsif (fd = launcher.binder.activated_sockets.delete([:acme, uri.host, uri.port]))
105
+ io = launcher.binder.inherit_ssl_listener(fd, ctx)
106
+ @log_writer.log "* Activated #{str}"
107
+ else
108
+ io = launcher.binder.add_ssl_listener(uri.host, uri.port, ctx)
109
+ end
110
+
111
+ cert.identifiers.each do |identifier|
112
+ @log_writer.log "* Listening on ssl://#{uri.host}:#{uri.port} for https://#{identifier.value} (puma-acme)"
113
+ end
114
+
115
+ launcher.binder.listeners << [str, io]
116
+ end
117
+ end
118
+ end
119
+
120
+ def provision(cert, poll_interval:)
121
+ unless @manager.account
122
+ @log_writer.debug 'Acme: creating account'
123
+
124
+ @manager.account!
125
+ end
126
+
127
+ if cert.order.nil?
128
+ @log_writer.debug 'Acme: creating order'
129
+ @manager.order!(cert)
130
+ else
131
+ @manager.reload!(cert)
132
+ end
133
+
134
+ loop do
135
+ case cert.order.status.to_sym
136
+ when :valid
137
+ @log_writer.debug 'Acme: downloading cert'
138
+
139
+ @manager.download!(cert)
140
+
141
+ return
142
+ when :processing, :pending
143
+ @log_writer.debug "Acme: waiting on #{cert.order.status} order"
144
+
145
+ sleep poll_interval
146
+
147
+ @manager.reload!(cert)
148
+ when :ready
149
+ @log_writer.debug 'Acme: finalizing ready order'
150
+
151
+ @manager.finalize!(cert)
152
+ when :invalid
153
+ @log_writer.debug 'Acme: invalid order, re-ordering'
154
+
155
+ @manager.order!(cert)
156
+ end
157
+ end
158
+ rescue StaleCert
159
+ sleep poll_interval
160
+ retry
161
+ end
162
+
163
+ def renew(cert, renew_at:, renew_interval:, poll_interval:)
164
+ if cert.order.status.to_sym != :valid
165
+ # finish provisioning aborted renewal
166
+ return provision(cert, poll_interval: poll_interval)
167
+ end
168
+
169
+ loop do
170
+ sleep renew_interval
171
+
172
+ next unless cert.renewable?(renew_at)
173
+
174
+ @log_writer.debug 'Acme: creating renewal order'
175
+
176
+ @manager.order!(cert)
177
+
178
+ return provision(cert, poll_interval: poll_interval)
179
+ end
180
+ rescue StaleCert
181
+ sleep poll_interval
182
+ retry
183
+ end
184
+
185
+ def disk_store(options)
186
+ cache_dir = options[:acme_cache_dir] || 'tmp/acme'
187
+
188
+ FileUtils.mkdir_p(cache_dir)
189
+
190
+ DiskStore.new(cache_dir)
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ module Acme
5
+ # https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.2
6
+ Account = Struct.new(:directory, :url, :status, :contact, :tos_agreed, :eab, :jwk, :kid, :key_pem,
7
+ keyword_init: true) do
8
+ def self.key(directory:, contact: nil, eab: nil)
9
+ new(directory: directory, contact: contact, eab: eab).key
10
+ end
11
+
12
+ def self.from(acme_account)
13
+ new(acme_account.to_h.slice(*members))
14
+ end
15
+
16
+ def key
17
+ [:account, directory, contact, eab&.key]
18
+ end
19
+ end
20
+
21
+ Answer = Struct.new(:type, :token, :value, keyword_init: true) do
22
+ def self.from(acme_challenge)
23
+ new(
24
+ type: acme_challenge.challenge_type,
25
+ token: acme_challenge.token,
26
+ value: acme_challenge.key_authorization
27
+ )
28
+ end
29
+
30
+ def self.key(type:, token:)
31
+ new(type: type, token: token).key
32
+ end
33
+
34
+ def key
35
+ [:answer, type, token]
36
+ end
37
+ end
38
+
39
+ # https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.4
40
+ Authz = Struct.new(:url, :identifier, :status, :expires, :challenges, :wildcard, keyword_init: true) do
41
+ def self.from(acme_authz)
42
+ identifier = Identifier.from(acme_authz.identifier)
43
+ challenges = acme_authz.challenges
44
+ .reject { |c| c.is_a?(::Acme::Client::Resources::Challenges::Unsupported) }
45
+ .map { |c| Challenge.from(c) }
46
+
47
+ new(acme_authz.to_h.slice(*members).merge(challenges: challenges, identifier: identifier))
48
+ end
49
+ end
50
+
51
+ Cert = Struct.new(:algorithm, :identifiers, :cert_pem, :key_pem, :order, keyword_init: true) do
52
+ def self.key(algorithm:, identifiers:)
53
+ new(algorithm: algorithm, identifiers: identifiers).key
54
+ end
55
+
56
+ def initialize(identifiers: nil, **kwargs)
57
+ identifiers = identifiers&.map { |value| Identifier.parse(value) }
58
+
59
+ super(identifiers: identifiers, **kwargs)
60
+ end
61
+
62
+ def key
63
+ [:cert, algorithm, identifiers&.map(&:key)]
64
+ end
65
+
66
+ def names
67
+ identifiers&.map(&:value)
68
+ end
69
+
70
+ def usable?(now: Time.now.utc)
71
+ !cert_pem.nil? && !key_pem.nil? && x509.not_after > now
72
+ end
73
+
74
+ def renewable?(renew_in, now: Time.now.utc)
75
+ case renew_in
76
+ when Float
77
+ renew_at = x509.not_before + (x509.not_after - x509.not_before) * renew_in
78
+ when Integer
79
+ renew_at = x509.not_before + renew_in
80
+ else
81
+ raise UnknownRenewIn, renew_in
82
+ end
83
+
84
+ now >= renew_at
85
+ end
86
+
87
+ protected
88
+
89
+ def x509
90
+ @x509 ||= OpenSSL::X509::Certificate.new(cert_pem) if cert_pem
91
+ end
92
+ end
93
+
94
+ # https://www.rfc-editor.org/rfc/rfc8555.html#section-8
95
+ Challenge = Struct.new(:url, :type, :token, :error, :answer, keyword_init: true) do
96
+ def self.from(acme_challenge)
97
+ new(acme_challenge.to_h.slice(*members).merge(type: acme_challenge.challenge_type,
98
+ answer: Answer.from(acme_challenge)))
99
+ end
100
+ end
101
+
102
+ Eab = Struct.new(:kid, :hmac_key, keyword_init: true) do
103
+ def key
104
+ [:eab, kid]
105
+ end
106
+ end
107
+
108
+ Identifier = Struct.new(:type, :value, keyword_init: true) do
109
+ def self.parse(value)
110
+ # TODO: ip identifiers
111
+ new(type: :dns, value: value)
112
+ end
113
+
114
+ def self.from(acme_identifier)
115
+ new(acme_identifier.to_h.slice(*members.map(&:to_s)))
116
+ end
117
+
118
+ def key
119
+ [:identifier, type, value]
120
+ end
121
+ end
122
+
123
+ # https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.3
124
+ Order = Struct.new(:url, :status, :expires, :identifiers, :not_before, :not_after, :error, :authorizations,
125
+ keyword_init: true) do
126
+ def self.from(acme_order)
127
+ identifiers = acme_order.identifiers.map { |i| Identifier.new(i) }
128
+ authorizations = acme_order.authorizations.map { |a| Authz.from(a) }
129
+
130
+ new(acme_order.to_h.slice(*members).merge(identifiers: identifiers, authorizations: authorizations))
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ module Acme
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
data/lib/puma/acme.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'acme-client'
4
+ require 'fileutils'
5
+ require 'pstore'
6
+ require 'puma'
7
+ require 'puma/binder'
8
+ require 'puma/plugin'
9
+ require 'sinatra'
10
+
11
+ module Puma
12
+ # This is a plugin for Puma that will automatically provision a SSL
13
+ # certificate.
14
+ module Acme
15
+ class Error < StandardError; end
16
+ class StaleCert < Error; end
17
+ class UnknownAlgorithmError < Error; end
18
+ class UnknownMode < Error; end
19
+
20
+ CHALLENGE_TYPE = ::Acme::Client::Resources::Challenges::HTTP01::CHALLENGE_TYPE
21
+ DEFAULT_DIRECTORY = 'https://acme-v02.api.letsencrypt.org/directory'
22
+ DEFAULT_RENEW_INTERVAL = 60 * 60
23
+ end
24
+ end
25
+
26
+ require_relative './acme/binder'
27
+ require_relative './acme/disk_store'
28
+ require_relative './acme/dsl'
29
+ require_relative './acme/manager'
30
+ require_relative './acme/middleware'
31
+ require_relative './acme/plugin'
32
+ require_relative './acme/structs'
33
+ require_relative './acme/version'
data/lib/puma-acme.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'puma/acme'
metadata ADDED
@@ -0,0 +1,190 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: puma-acme
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Anchor Security, Inc
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-12-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: acme-client
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 2.0.13
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 2.0.13
27
+ - !ruby/object:Gem::Dependency
28
+ name: pstore
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: puma
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '6.4'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '6.4'
55
+ - !ruby/object:Gem::Dependency
56
+ name: sinatra
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.1'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.1'
69
+ - !ruby/object:Gem::Dependency
70
+ name: http.rb
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.12'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.12'
83
+ - !ruby/object:Gem::Dependency
84
+ name: minitest
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '5.14'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '5.14'
97
+ - !ruby/object:Gem::Dependency
98
+ name: minitest-mock_expectations
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.2'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.2'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rake
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '13.0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '13.0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: vcr
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '6.1'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '6.1'
139
+ description: A Puma webserver plugin for automatic access to certificates from Let's
140
+ Encrypt and any other ACME-based CA.
141
+ email:
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files:
145
+ - LICENSE.txt
146
+ - README.md
147
+ - CHANGELOG.md
148
+ files:
149
+ - CHANGELOG.md
150
+ - Gemfile
151
+ - Gemfile.lock
152
+ - LICENSE.txt
153
+ - README.md
154
+ - Rakefile
155
+ - lib/puma-acme.rb
156
+ - lib/puma/acme.rb
157
+ - lib/puma/acme/binder.rb
158
+ - lib/puma/acme/disk_store.rb
159
+ - lib/puma/acme/dsl.rb
160
+ - lib/puma/acme/manager.rb
161
+ - lib/puma/acme/middleware.rb
162
+ - lib/puma/acme/plugin.rb
163
+ - lib/puma/acme/structs.rb
164
+ - lib/puma/acme/version.rb
165
+ homepage: https://github.com/anchordotdev/puma-acme
166
+ licenses:
167
+ - MIT
168
+ metadata:
169
+ homepage_uri: https://github.com/anchordotdev/puma-acme
170
+ rubygems_mfa_required: 'true'
171
+ post_install_message:
172
+ rdoc_options: []
173
+ require_paths:
174
+ - lib
175
+ required_ruby_version: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: '2.5'
180
+ required_rubygems_version: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - ">="
183
+ - !ruby/object:Gem::Version
184
+ version: '0'
185
+ requirements: []
186
+ rubygems_version: 3.3.26
187
+ signing_key:
188
+ specification_version: 4
189
+ summary: Puma plugin for ACME.
190
+ test_files: []