puma-acme 0.1.0
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/CHANGELOG.md +5 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +58 -0
- data/LICENSE.txt +21 -0
- data/README.md +121 -0
- data/Rakefile +11 -0
- data/lib/puma/acme/binder.rb +19 -0
- data/lib/puma/acme/disk_store.rb +40 -0
- data/lib/puma/acme/dsl.rb +75 -0
- data/lib/puma/acme/manager.rb +160 -0
- data/lib/puma/acme/middleware.rb +28 -0
- data/lib/puma/acme/plugin.rb +194 -0
- data/lib/puma/acme/structs.rb +134 -0
- data/lib/puma/acme/version.rb +7 -0
- data/lib/puma/acme.rb +33 -0
- data/lib/puma-acme.rb +3 -0
- metadata +190 -0
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
data/Gemfile
ADDED
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,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
|
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
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: []
|