anchor-pki 0.5.0 → 0.6.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 +4 -4
- data/Gemfile.lock +18 -4
- data/README.md +4 -2
- data/lib/anchor/auto_cert/configuration.rb +109 -62
- data/lib/anchor/auto_cert/identifier_policy.rb +4 -1
- data/lib/anchor/auto_cert/managed_certificate.rb +39 -29
- data/lib/anchor/auto_cert/manager.rb +95 -37
- data/lib/anchor/auto_cert/railtie.rb +4 -1
- data/lib/anchor/auto_cert/renewal_busy_wait.rb +5 -4
- data/lib/anchor/disk_store.rb +31 -0
- data/lib/anchor/pem_bundle.rb +79 -0
- data/lib/anchor/version.rb +1 -1
- data/lib/anchor.rb +2 -0
- data/lib/puma/plugin/auto_cert.rb +16 -11
- metadata +32 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f6f3aa56dc5a365db7f3c2b4f255ac538ba455fad1bdf758f19450bca287381e
|
4
|
+
data.tar.gz: 6bf38bec006856c4a60246e3b53a119296e9c4557a17f3c4d0f5ef50404102ca
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5d24d34f6c5448e2ba33ac004e6d2e99a7ff0c3b31840c119f5d0256576d6146e6e13b120251db58ab9d80db4f4927f1c71f2c38397ca434a66ded8124c300e8
|
7
|
+
data.tar.gz: 30ba030f48985e35f6d63e1ac02943bf0bed301eba7a36cc6728caa5dbee0395e14d8a4ba3e2d43edf68fca461ee5cb60ae204542b6f4f30c1ffa3655cf68986
|
data/Gemfile.lock
CHANGED
@@ -1,22 +1,26 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
anchor-pki (0.
|
4
|
+
anchor-pki (0.6.1)
|
5
5
|
acme-client (~> 2.0.13)
|
6
|
+
pstore (~> 0.1)
|
6
7
|
|
7
8
|
GEM
|
8
9
|
remote: https://rubygems.org/
|
9
10
|
specs:
|
10
|
-
acme-client (2.0.
|
11
|
+
acme-client (2.0.15)
|
11
12
|
faraday (>= 1.0, < 3.0.0)
|
12
13
|
faraday-retry (>= 1.0, < 3.0.0)
|
13
14
|
addressable (2.8.4)
|
14
15
|
public_suffix (>= 2.0.2, < 6.0)
|
15
16
|
ast (2.4.2)
|
17
|
+
base64 (0.2.0)
|
16
18
|
crack (0.4.5)
|
17
19
|
rexml
|
18
20
|
diff-lcs (1.5.0)
|
19
|
-
|
21
|
+
docile (1.4.0)
|
22
|
+
faraday (2.7.12)
|
23
|
+
base64
|
20
24
|
faraday-net_http (>= 2.0, < 3.1)
|
21
25
|
ruby2_keywords (>= 0.0.4)
|
22
26
|
faraday-net_http (3.0.2)
|
@@ -28,9 +32,10 @@ GEM
|
|
28
32
|
parallel (1.23.0)
|
29
33
|
parser (3.2.2.1)
|
30
34
|
ast (~> 2.4.1)
|
35
|
+
pstore (0.1.3)
|
31
36
|
public_suffix (5.0.1)
|
32
37
|
rainbow (3.1.1)
|
33
|
-
rake (13.0
|
38
|
+
rake (13.1.0)
|
34
39
|
regexp_parser (2.8.0)
|
35
40
|
rexml (3.2.5)
|
36
41
|
rspec (3.12.0)
|
@@ -68,6 +73,12 @@ GEM
|
|
68
73
|
rubocop-factory_bot (~> 2.22)
|
69
74
|
ruby-progressbar (1.13.0)
|
70
75
|
ruby2_keywords (0.0.5)
|
76
|
+
simplecov (0.22.0)
|
77
|
+
docile (~> 1.1)
|
78
|
+
simplecov-html (~> 0.11)
|
79
|
+
simplecov_json_formatter (~> 0.1)
|
80
|
+
simplecov-html (0.12.3)
|
81
|
+
simplecov_json_formatter (0.1.4)
|
71
82
|
unicode-display_width (2.4.2)
|
72
83
|
vcr (6.1.0)
|
73
84
|
webmock (3.18.1)
|
@@ -78,7 +89,9 @@ GEM
|
|
78
89
|
PLATFORMS
|
79
90
|
aarch64-linux
|
80
91
|
arm64-darwin-21
|
92
|
+
arm64-darwin-23
|
81
93
|
x86_64-darwin-22
|
94
|
+
x86_64-linux
|
82
95
|
|
83
96
|
DEPENDENCIES
|
84
97
|
anchor-pki!
|
@@ -87,6 +100,7 @@ DEPENDENCIES
|
|
87
100
|
rspec (~> 3.9)
|
88
101
|
rubocop (~> 1.50)
|
89
102
|
rubocop-rspec (~> 2.22)
|
103
|
+
simplecov (~> 0.22)
|
90
104
|
vcr (~> 6.1)
|
91
105
|
webmock (~> 3.8)
|
92
106
|
|
data/README.md
CHANGED
@@ -9,9 +9,10 @@ The Following environment variables are available to configure the default
|
|
9
9
|
|
10
10
|
* `HTTPS_PORT` - the TCP numerical port to bind SSL to.
|
11
11
|
* `ACME_ALLOW_IDENTIFIERS` - A comma separated list of hostnames for provisioning certs
|
12
|
+
* `ACME_CONTACT` - URL to contact in case of issues with the account
|
12
13
|
* `ACME_DIRECTORY_URL` - the ACME provider's directory
|
14
|
+
* `ACME_HMAC_KEY` - your External Account Binding (EAB) HMAC_KEY for authenticating with the ACME directory above
|
13
15
|
* `ACME_KID` - your External Account Binding (EAB) KID for authenticating with the ACME directory above with an
|
14
|
-
* `ACME_HMAC_KEY` - your EAB HMAC_KEY for authenticating with the ACME directory above
|
15
16
|
* `ACME_RENEW_BEFORE_SECONDS` - **optional** Start a renewal this number number of seconds before the cert expires. This defaults to 30 days (2592000 seconds)
|
16
17
|
* `ACME_RENEW_BEFORE_FRACTION` - **optional** Start the renewal when this fraction of a cert's valid window is left. This defaults to 0.5, which means when the cert is in the last 50% of its lifespan a renewal is attempted.
|
17
18
|
* `AUTO_CERT_CHECK_EVERY` - **optional** the number of seconds to wait between checking if the certificate has expired. This defaults to 1 hour (3600 seconds)
|
@@ -58,7 +59,8 @@ regenerated periodically.
|
|
58
59
|
export ACME_DIRECTORY_URL='https://anchor.dev/autocert-cab3bc/development/x509/ca/acme'
|
59
60
|
export ACME_KID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
60
61
|
export ACME_HMAC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
61
|
-
|
62
|
+
1. Update the [./spec/spec_helper.rb](spec/spec_helper.rb) file with these
|
63
|
+
values as the respective `VCR_KID` and `VCR_HMAC_KEY`.
|
62
64
|
1. on the command line execute:
|
63
65
|
|
64
66
|
$ . .env
|
@@ -5,8 +5,9 @@ module Anchor
|
|
5
5
|
# AutoCert Configuration provides a way to configure the AutoCert Manager.
|
6
6
|
#
|
7
7
|
class Configuration
|
8
|
-
|
9
|
-
|
8
|
+
DEFAULT_RENEW_BEFORE_SECONDS = 60 * 60 * 24 * 30 # 30 days in seconds
|
9
|
+
DEFAULT_RENEW_BEFORE_FRACTION = 0.5 # when in the last 50% of the validity window, renew
|
10
|
+
DEFAULT_CHECK_EVERY_SECONDS = 60 * 60 # 1 day in seconds
|
10
11
|
|
11
12
|
# Note - although it is possible to set change the name of a config, it is
|
12
13
|
# not recommended. The name is used as the key in the Registry, and if a
|
@@ -14,7 +15,8 @@ module Anchor
|
|
14
15
|
# change its registry key.
|
15
16
|
attr_accessor :name,
|
16
17
|
:allow_identifiers,
|
17
|
-
:
|
18
|
+
:cache_dir,
|
19
|
+
:check_every_seconds,
|
18
20
|
:contact,
|
19
21
|
:directory_url,
|
20
22
|
:external_account_binding,
|
@@ -30,19 +32,21 @@ module Anchor
|
|
30
32
|
#
|
31
33
|
def initialize(name:,
|
32
34
|
allow_identifiers: nil,
|
33
|
-
|
35
|
+
cache_dir: nil,
|
36
|
+
check_every_seconds: nil,
|
34
37
|
contact: nil,
|
35
38
|
directory_url: nil,
|
36
39
|
external_account_binding: nil,
|
37
|
-
renew_before_fraction:
|
38
|
-
renew_before_seconds:
|
40
|
+
renew_before_fraction: nil,
|
41
|
+
renew_before_seconds: nil,
|
39
42
|
tos_acceptors: nil,
|
40
43
|
work_dir: nil)
|
41
44
|
|
42
45
|
@name = name
|
43
46
|
|
44
47
|
@allow_identifiers = allow_identifiers
|
45
|
-
@
|
48
|
+
@cache_dir = cache_dir
|
49
|
+
@check_every_seconds = check_every_seconds
|
46
50
|
@contact = contact
|
47
51
|
@directory_url = directory_url
|
48
52
|
@external_account_binding = external_account_binding
|
@@ -70,17 +74,32 @@ module Anchor
|
|
70
74
|
|
71
75
|
def validate!
|
72
76
|
@allow_identifiers = prepare_allow_identifiers(@allow_identifiers)
|
73
|
-
@
|
77
|
+
@cache_dir = prepare_directory(dir: @cache_dir, property: 'cache_dir')
|
78
|
+
@check_every_seconds = prepare_check_every_seconds(@check_every_seconds)
|
79
|
+
@contact = prepare_contact(@contact)
|
74
80
|
@directory_url = prepare_directory_url(@directory_url)
|
75
81
|
@external_account_binding = prepare_external_account_binding(@external_account_binding)
|
76
82
|
@renew_before_fraction = prepare_renew_before_fraction(@renew_before_fraction)
|
77
83
|
@renew_before_seconds = prepare_renew_before_seconds(@renew_before_seconds)
|
78
84
|
@tos_acceptors = prepare_tos_acceptors(@tos_acceptors)
|
79
|
-
@work_dir =
|
80
|
-
|
85
|
+
@work_dir = prepare_directory(dir: @work_dir, property: 'work_dir')
|
81
86
|
self
|
82
87
|
end
|
83
88
|
|
89
|
+
# Return the fallback identifer for this configuration
|
90
|
+
|
91
|
+
# look at all the identifiers, strip a leading wildcard off of all of
|
92
|
+
# them and then pick the one that has the fewest '.' in it, if there are
|
93
|
+
# ties for fewest, pick the first one in the list of ties. A minimum of
|
94
|
+
# 2 '.' is required.
|
95
|
+
#
|
96
|
+
def fallback_identifier
|
97
|
+
de_wildcarded = allow_identifiers.map { |i| i.sub(/^\*\./, '') }
|
98
|
+
not_tld = de_wildcarded.select { |i| i.count('.') >= 2 }
|
99
|
+
ordered = not_tld.sort_by { |i| i.count('.') }
|
100
|
+
ordered[0]
|
101
|
+
end
|
102
|
+
|
84
103
|
private
|
85
104
|
|
86
105
|
def prepare_allow_identifiers(allow_identifiers)
|
@@ -95,33 +114,42 @@ module Anchor
|
|
95
114
|
|
96
115
|
if prepared.nil? || prepared.empty?
|
97
116
|
raise ConfigurationError,
|
98
|
-
"The '#{name}' #{self.class} instance has a misconfigured
|
99
|
-
'Set it to a string, or an array of strings, ' \
|
100
|
-
'or set the ACME_ALLOW_IDENTIFIERS environment variable
|
117
|
+
"The '#{name}' #{self.class} instance has a misconfigured " \
|
118
|
+
'`allow_identifiers` value. Set it to a string, or an array of strings, ' \
|
119
|
+
'or set the ACME_ALLOW_IDENTIFIERS environment variable ' \
|
120
|
+
'to a comma separated list of identifiers.'
|
101
121
|
end
|
102
122
|
|
103
123
|
prepared
|
104
124
|
end
|
105
125
|
|
106
|
-
def
|
107
|
-
|
126
|
+
def prepare_check_every_seconds(check_every_seconds)
|
127
|
+
message = "The '#{name}' #{self.class} instance has a misconfigured " \
|
128
|
+
'`check_every_seconds` value. It must be set to an integer > 0, ' \
|
129
|
+
'or set the AUTO_CERT_CHECK_EVERY environment variable.'
|
108
130
|
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
131
|
+
candidates = [
|
132
|
+
check_every_seconds,
|
133
|
+
ENV.fetch('AUTO_CERT_CHECK_EVERY', nil),
|
134
|
+
DEFAULT_CHECK_EVERY_SECONDS
|
135
|
+
]
|
136
|
+
|
137
|
+
ensure_positive_integer(candidates, message)
|
138
|
+
end
|
114
139
|
|
115
|
-
|
140
|
+
def prepare_contact(contact)
|
141
|
+
contact ||= ENV.fetch('ACME_CONTACT', nil)
|
142
|
+
|
143
|
+
contact
|
116
144
|
end
|
117
145
|
|
118
146
|
def prepare_directory_url(directory_url)
|
147
|
+
message = "The '#{name}' #{self.class} instance has a misconfigured `directory_url` value. " \
|
148
|
+
'It must be set to a string, or set the ACME_DIRECTORY_URL environment variable.'
|
149
|
+
|
119
150
|
directory_url ||= ENV.fetch('ACME_DIRECTORY_URL', nil)
|
120
|
-
|
121
|
-
|
122
|
-
"The '#{name}' #{self.class} instance has a misconfigured `directory_url` value. " \
|
123
|
-
'It must be set to a string, or set the ACME_DIRECTORY_URL environment variable.'
|
124
|
-
end
|
151
|
+
|
152
|
+
raise ConfigurationError, message if directory_url.nil?
|
125
153
|
|
126
154
|
directory_url
|
127
155
|
end
|
@@ -130,39 +158,47 @@ module Anchor
|
|
130
158
|
kid = ENV.fetch('ACME_KID', nil)
|
131
159
|
hmac_key = ENV.fetch('ACME_HMAC_KEY', nil)
|
132
160
|
|
133
|
-
if kid && hmac_key
|
134
|
-
external_account_binding
|
135
|
-
kid: kid,
|
136
|
-
hmac_key: hmac_key
|
137
|
-
}
|
161
|
+
if external_account_binding && external_account_binding[:kid] && external_account_binding[:hmac_key]
|
162
|
+
return external_account_binding
|
138
163
|
end
|
139
|
-
|
164
|
+
|
165
|
+
{ kid: kid, hmac_key: hmac_key }
|
140
166
|
end
|
141
167
|
|
142
168
|
def prepare_renew_before_seconds(renew_before_seconds)
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
169
|
+
message = "The '#{name}' #{self.class} instance has a misconfigured " \
|
170
|
+
'`before_seconds` value. It must be set to an integer > 0, ' \
|
171
|
+
'or set the ACME_RENEW_BEFORE_SECONDS environment variable.'
|
172
|
+
|
173
|
+
candidates = [
|
174
|
+
renew_before_seconds,
|
175
|
+
ENV.fetch('ACME_RENEW_BEFORE_SECONDS', nil),
|
176
|
+
DEFAULT_RENEW_BEFORE_SECONDS
|
177
|
+
]
|
178
|
+
ensure_positive_integer(candidates, message)
|
153
179
|
end
|
154
180
|
|
155
181
|
def prepare_renew_before_fraction(renew_before_fraction)
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
182
|
+
message = "The '#{name}' #{self.class} instance has a misconfigured " \
|
183
|
+
'`before_fraction` value. It must be set to a float > 0 and < 1, ' \
|
184
|
+
'or set the ACME_RENEW_BEFORE_FRACTION environment variable.'
|
185
|
+
|
186
|
+
candidates = [
|
187
|
+
renew_before_fraction,
|
188
|
+
ENV.fetch('ACME_RENEW_BEFORE_FRACTION', nil),
|
189
|
+
DEFAULT_RENEW_BEFORE_FRACTION
|
190
|
+
]
|
191
|
+
|
192
|
+
candidates.each do |candidate|
|
193
|
+
next if candidate.nil?
|
194
|
+
|
195
|
+
as_float = candidate.to_f
|
196
|
+
return as_float if (0..1).cover?(as_float)
|
164
197
|
end
|
165
|
-
|
198
|
+
|
199
|
+
# this should really never happen as DEFAULT_RENEW_BEFORE_FRACTION is
|
200
|
+
# valid
|
201
|
+
raise ConfigurationError, message
|
166
202
|
end
|
167
203
|
|
168
204
|
def prepare_tos_acceptors(tos_acceptors)
|
@@ -170,29 +206,40 @@ module Anchor
|
|
170
206
|
|
171
207
|
if tos_acceptors.empty? || tos_acceptors.any? { |tos| !tos.respond_to?(:accept?) }
|
172
208
|
raise ConfigurationError,
|
173
|
-
"The '#{name}' #{self.class} instance has a misconfigured
|
174
|
-
'It must be set to an object
|
209
|
+
"The '#{name}' #{self.class} instance has a misconfigured " \
|
210
|
+
'`tos_acceptors` value. It must be set to an object ' \
|
211
|
+
'or an array of objects that respond to `accept?`.'
|
175
212
|
end
|
176
213
|
|
177
214
|
tos_acceptors
|
178
215
|
end
|
179
216
|
|
180
|
-
def
|
181
|
-
return nil if
|
217
|
+
def prepare_directory(dir:, property:)
|
218
|
+
return nil if dir.nil?
|
182
219
|
|
183
|
-
|
220
|
+
dir = Pathname.new(dir) unless dir.is_a?(Pathname)
|
221
|
+
message = "The '#{name}' #{self.class} instance has a misconfigured " \
|
222
|
+
"`#{property}` value, it resolves to (#{dir}). " \
|
223
|
+
'It must be set to a directory, or a path that can be created.'
|
184
224
|
|
185
225
|
begin
|
186
|
-
|
187
|
-
rescue StandardError =>
|
188
|
-
raise ConfigurationError,
|
226
|
+
dir.mkpath
|
227
|
+
rescue StandardError => _e
|
228
|
+
raise ConfigurationError, message
|
189
229
|
end
|
190
230
|
|
191
|
-
|
192
|
-
|
231
|
+
dir
|
232
|
+
end
|
233
|
+
|
234
|
+
def ensure_positive_integer(candidates, message)
|
235
|
+
candidates.each do |candidate|
|
236
|
+
next if candidate.nil?
|
237
|
+
|
238
|
+
as_int = candidate.to_i
|
239
|
+
return as_int if as_int.positive?
|
193
240
|
end
|
194
241
|
|
195
|
-
|
242
|
+
raise ConfigurationError, message
|
196
243
|
end
|
197
244
|
end
|
198
245
|
end
|
@@ -47,7 +47,10 @@ module Anchor
|
|
47
47
|
check_klass = self.class.policy_checks.find do |klass|
|
48
48
|
klass.handles?(description)
|
49
49
|
end
|
50
|
-
|
50
|
+
if check_klass.nil?
|
51
|
+
raise UnknownPolicyCheckError,
|
52
|
+
"Unable to create a policy check based upon '#{description}'"
|
53
|
+
end
|
51
54
|
|
52
55
|
@description = description
|
53
56
|
@check = check_klass.new(description)
|
@@ -4,32 +4,26 @@ require 'forwardable'
|
|
4
4
|
|
5
5
|
module Anchor
|
6
6
|
module AutoCert
|
7
|
-
# ManagedCertificate is a class that represents a certificate
|
7
|
+
# ManagedCertificate is a class that represents a certificate
|
8
8
|
# for renewal
|
9
9
|
class ManagedCertificate
|
10
|
-
attr_reader :cert_pem, :
|
10
|
+
attr_reader :cert_pem, :key_pem, :x509, :persist_dir, :cert_path, :private_key_path
|
11
11
|
|
12
12
|
extend Forwardable
|
13
|
-
def_delegators :@
|
14
|
-
def_delegators :@x509, :not_after, :not_before, :serial
|
13
|
+
def_delegators :@x509, :not_after, :not_before
|
15
14
|
|
16
|
-
def
|
17
|
-
cert_pem
|
18
|
-
key_pem
|
19
|
-
new(
|
15
|
+
def initialize(cert_pem:, key_pem:, persist_dir: nil)
|
16
|
+
@cert_pem = cert_pem
|
17
|
+
@key_pem = key_pem
|
18
|
+
@persist_dir = Pathname.new(persist_dir) if persist_dir
|
19
|
+
@x509 = OpenSSL::X509::Certificate.new(cert_pem)
|
20
|
+
@cert_path = nil
|
21
|
+
@private_key_path = nil
|
22
|
+
persist_pems
|
20
23
|
end
|
21
24
|
|
22
|
-
def
|
23
|
-
|
24
|
-
@cert_pem = cert_pem
|
25
|
-
@key_pem = key_pem
|
26
|
-
@x509 = OpenSSL::X509::Certificate.new(cert_pem)
|
27
|
-
|
28
|
-
hex_serial_basename = hex_serial('-')
|
29
|
-
@cert_path = manager.work_dir / "#{hex_serial_basename}.crt"
|
30
|
-
@key_path = manager.work_dir / "#{hex_serial_basename}.key"
|
31
|
-
|
32
|
-
write_working_files
|
25
|
+
def serial
|
26
|
+
x509.serial.to_i
|
33
27
|
end
|
34
28
|
|
35
29
|
def hex_serial(joiner = ':')
|
@@ -40,27 +34,43 @@ module Anchor
|
|
40
34
|
not_after <= now
|
41
35
|
end
|
42
36
|
|
43
|
-
|
44
|
-
|
45
|
-
end
|
46
|
-
|
37
|
+
# For the moment, the only items in subjectAltName we care about are DNS:
|
38
|
+
# entries.
|
47
39
|
def identifiers
|
48
40
|
alt_names = x509&.extensions&.find { |ext| ext.oid == 'subjectAltName' }&.value&.split(', ') || []
|
49
|
-
alt_names.
|
41
|
+
alt_names.select { |name| name.start_with?('DNS:') }
|
42
|
+
.map { |name| name.sub(/^DNS:/, '') }
|
50
43
|
end
|
51
44
|
|
52
45
|
def common_name
|
53
46
|
x509.subject.to_a.find { |name, _, _| name == 'CN' }[1]
|
54
47
|
end
|
55
48
|
|
56
|
-
def
|
57
|
-
|
58
|
-
|
49
|
+
def all_names
|
50
|
+
non_common_identifiers = identifiers.reject { |name| name == common_name }
|
51
|
+
[common_name, *non_common_identifiers.sort]
|
52
|
+
end
|
53
|
+
|
54
|
+
def persist_pems
|
55
|
+
return unless persist_dir
|
56
|
+
return nil unless persist_dir.directory? && persist_dir.writable?
|
57
|
+
|
58
|
+
@cert_path = persist_dir.join("#{serial}.crt")
|
59
|
+
@cert_path.write(cert_pem)
|
60
|
+
|
61
|
+
@private_key_path = persist_dir.join("#{serial}.key")
|
62
|
+
@private_key_path.write(key_pem)
|
59
63
|
end
|
60
64
|
|
61
65
|
def purge_working_files
|
62
|
-
|
63
|
-
|
66
|
+
return unless persist_dir
|
67
|
+
return nil unless persist_dir.directory? && persist_dir.writable?
|
68
|
+
|
69
|
+
[@cert_path, @private_key_path].each do |path|
|
70
|
+
next unless path&.exist? && path&.writable?
|
71
|
+
|
72
|
+
path.delete
|
73
|
+
end
|
64
74
|
end
|
65
75
|
end
|
66
76
|
end
|
@@ -15,19 +15,22 @@ module Anchor
|
|
15
15
|
|
16
16
|
extend Forwardable
|
17
17
|
def_delegators :@configuration,
|
18
|
-
:
|
18
|
+
:check_every_seconds,
|
19
|
+
:cache_dir,
|
19
20
|
:contact,
|
20
21
|
:directory,
|
21
22
|
:external_account_binding,
|
23
|
+
:fallback_identifier,
|
22
24
|
:renew_before_fraction,
|
23
|
-
:renew_before_seconds
|
25
|
+
:renew_before_seconds,
|
26
|
+
:work_dir
|
24
27
|
|
25
|
-
attr_reader :
|
28
|
+
attr_reader :disk_store,
|
29
|
+
:client,
|
26
30
|
:configuration,
|
27
31
|
:directory_url,
|
28
32
|
:identifier_policies,
|
29
|
-
:tos_acceptors
|
30
|
-
:work_dir
|
33
|
+
:tos_acceptors
|
31
34
|
|
32
35
|
def self.for(configuration)
|
33
36
|
new(configuration: configuration)
|
@@ -37,10 +40,12 @@ module Anchor
|
|
37
40
|
configuration.validate!
|
38
41
|
@configuration = configuration
|
39
42
|
|
43
|
+
# disk store early since other things may use it
|
44
|
+
@disk_store = DiskStore.new(dir: @configuration.cache_dir, basename: 'autocert-manager')
|
45
|
+
|
40
46
|
@identifier_policies = IdentifierPolicy.build(@configuration.allow_identifiers)
|
41
47
|
@tos_acceptors = Array(@configuration.tos_acceptors)
|
42
48
|
@directory_url = URI.parse(@configuration.directory_url)
|
43
|
-
@work_dir = Pathname.new(@configuration.work_dir || Dir.mktmpdir)
|
44
49
|
|
45
50
|
@account_opts = {
|
46
51
|
contact: @configuration.contact,
|
@@ -49,30 +54,63 @@ module Anchor
|
|
49
54
|
|
50
55
|
@client = client || new_client(contact: @configuration.contact)
|
51
56
|
@enabled = true
|
57
|
+
@managed_certificates = {}
|
52
58
|
end
|
53
59
|
|
54
60
|
# It is currently assumed that the common name is the first of the
|
55
61
|
# `identifiers` passed into this method. If that is not the case, then
|
56
62
|
# the `common_name` parameter needs to be set explicitly
|
57
|
-
def managed_certificate(identifiers:, algorithm: :ecdsa, common_name: Array(identifiers).first,
|
58
|
-
|
59
|
-
|
63
|
+
def managed_certificate(identifiers:, algorithm: :ecdsa, common_name: Array(identifiers).first,
|
64
|
+
now: Time.now.utc, **opts)
|
65
|
+
full_ids = consolidate_identifiers(common_name: common_name, identifiers: identifiers)
|
66
|
+
denied_ids = denied_identifiers(full_ids)
|
67
|
+
|
68
|
+
# Fallback to a configured identifier if the requested one(s) are denied
|
69
|
+
if denied_ids.any?
|
70
|
+
common_name = fallback_identifier
|
71
|
+
identifiers = []
|
60
72
|
end
|
61
73
|
|
62
|
-
|
63
|
-
|
74
|
+
# first look and see if its memory
|
75
|
+
managed_certificate = @managed_certificates[common_name]
|
76
|
+
if managed_certificate && !needs_renewal?(cert: managed_certificate, now: now)
|
77
|
+
return managed_certificate
|
78
|
+
end
|
79
|
+
|
80
|
+
# then look into the disk cache
|
81
|
+
if @disk_store
|
82
|
+
key_pem = @disk_store["#{common_name}.key.pem"]
|
83
|
+
cert_pem = @disk_store["#{common_name}.cert.pem"]
|
84
|
+
end
|
64
85
|
|
65
86
|
if !key_pem.nil? && !cert_pem.nil?
|
66
|
-
|
67
|
-
|
87
|
+
managed_certificate = ManagedCertificate.new(cert_pem: cert_pem,
|
88
|
+
key_pem: key_pem,
|
89
|
+
persist_dir: work_dir)
|
90
|
+
if managed_certificate && !needs_renewal?(cert: managed_certificate, now: now)
|
91
|
+
return managed_certificate
|
92
|
+
end
|
68
93
|
end
|
69
94
|
|
70
|
-
|
95
|
+
# and then provision a new one
|
96
|
+
cert_pem, key_pem = provision_or_fallback(
|
97
|
+
identifiers: identifiers, algorithm: algorithm,
|
98
|
+
common_name: common_name,
|
99
|
+
**opts
|
100
|
+
)
|
101
|
+
|
102
|
+
managed_certificate = ManagedCertificate.new(
|
103
|
+
cert_pem: cert_pem, key_pem: key_pem, persist_dir: work_dir
|
104
|
+
)
|
71
105
|
|
72
|
-
|
73
|
-
cache&.write(common_name, cert_pem)
|
106
|
+
@managed_certificates[common_name] = managed_certificate
|
74
107
|
|
75
|
-
|
108
|
+
if @disk_store
|
109
|
+
@disk_store["#{common_name}.key.pem"] = key_pem
|
110
|
+
@disk_store["#{common_name}.cert.pem"] = cert_pem
|
111
|
+
end
|
112
|
+
|
113
|
+
managed_certificate
|
76
114
|
end
|
77
115
|
|
78
116
|
def needs_renewal?(cert:, now: Time.now.utc)
|
@@ -97,12 +135,31 @@ module Anchor
|
|
97
135
|
|
98
136
|
private
|
99
137
|
|
138
|
+
def provision_or_fallback(identifiers:, algorithm:, common_name:, **opts)
|
139
|
+
cert_pem = nil
|
140
|
+
key_pem = nil
|
141
|
+
begin
|
142
|
+
cert_pem, key_pem = provision(
|
143
|
+
identifiers: identifiers, algorithm: algorithm, common_name: common_name,
|
144
|
+
**opts
|
145
|
+
)
|
146
|
+
rescue StandardError => _e
|
147
|
+
cert_pem, key_pem = provision(
|
148
|
+
identifiers: [], algorithm: algorithm, common_name: fallback_identifier,
|
149
|
+
**opts
|
150
|
+
)
|
151
|
+
end
|
152
|
+
[cert_pem, key_pem]
|
153
|
+
end
|
154
|
+
|
100
155
|
def provision(identifiers:, algorithm:, common_name:, **opts)
|
101
|
-
identifiers =
|
156
|
+
identifiers = consolidate_identifiers(common_name: common_name, identifiers: identifiers)
|
102
157
|
load_or_build_account
|
103
158
|
key_pem ||= new_key(algorithm).to_pem
|
104
|
-
csr = Acme::Client::CertificateRequest.new(
|
105
|
-
|
159
|
+
csr = Acme::Client::CertificateRequest.new(
|
160
|
+
common_name: common_name, names: identifiers,
|
161
|
+
private_key: parse_key_pem(key_pem)
|
162
|
+
)
|
106
163
|
|
107
164
|
order = @client.new_order(identifiers: identifiers, **opts)
|
108
165
|
order.finalize(csr: csr)
|
@@ -154,12 +211,13 @@ module Anchor
|
|
154
211
|
end
|
155
212
|
|
156
213
|
def new_client(account_key: nil, contact: nil, **)
|
157
|
-
account_key ||=
|
214
|
+
account_key ||= account_key_for(contact)
|
158
215
|
|
159
216
|
Acme::Client.new(private_key: account_key, directory: @directory_url)
|
160
217
|
end
|
161
218
|
|
162
|
-
|
219
|
+
# currently only using ecdsa algorithm
|
220
|
+
def new_key(algorithm = :ecdsa)
|
163
221
|
case algorithm
|
164
222
|
when :ecdsa then OpenSSL::PKey::EC.generate('prime256v1')
|
165
223
|
else
|
@@ -167,25 +225,19 @@ module Anchor
|
|
167
225
|
end
|
168
226
|
end
|
169
227
|
|
170
|
-
def
|
171
|
-
|
172
|
-
parse_key_pem(cache&.fetch(id) { parse_key_pem(yield.to_pem) } || yield.to_pem)
|
173
|
-
end
|
228
|
+
def account_key_for(contact)
|
229
|
+
return new_key unless @disk_store
|
174
230
|
|
175
|
-
|
176
|
-
|
177
|
-
|
231
|
+
account_key_id = "#{contact || 'default'}+#{@directory_url}+key"
|
232
|
+
pem = @disk_store[account_key_id]
|
233
|
+
return parse_key_pem(pem) if pem
|
178
234
|
|
179
|
-
|
235
|
+
raw_key = new_key
|
236
|
+
@disk_store[account_key_id] = raw_key.to_pem
|
237
|
+
raw_key
|
180
238
|
end
|
181
239
|
|
182
|
-
def
|
183
|
-
OpenSSL::PKey::RSA.new(data)
|
184
|
-
rescue StandardError
|
185
|
-
nil
|
186
|
-
end
|
187
|
-
|
188
|
-
def parse_ecdsa_pem(data)
|
240
|
+
def parse_key_pem(data)
|
189
241
|
OpenSSL::PKey::EC.new(data)
|
190
242
|
rescue StandardError
|
191
243
|
nil
|
@@ -197,6 +249,12 @@ module Anchor
|
|
197
249
|
@identifier_policies.any? { |policy| policy.allow?(identifier) }
|
198
250
|
end
|
199
251
|
end
|
252
|
+
|
253
|
+
# return a list of identifiers with duplicates removed
|
254
|
+
# preserving order with the common_name first
|
255
|
+
def consolidate_identifiers(common_name:, identifiers: [])
|
256
|
+
[common_name, *identifiers].compact.uniq
|
257
|
+
end
|
200
258
|
end
|
201
259
|
end
|
202
260
|
end
|
@@ -39,6 +39,9 @@ module Anchor
|
|
39
39
|
# to the `config.hosts` then HostAuthorization will be used, and tests
|
40
40
|
# will break.
|
41
41
|
unless Rails.env.test?
|
42
|
+
# load values from ENV
|
43
|
+
auto_cert_config&.validate! if Rails.configuration.auto_cert.enabled?
|
44
|
+
|
42
45
|
auto_cert_config&.allow_identifiers&.each do |identifier|
|
43
46
|
# need to convert an identifier into a host matcher, which is just
|
44
47
|
# strip off a leading '*' if it exists so that all subdomains match.
|
@@ -67,7 +70,7 @@ module Anchor
|
|
67
70
|
# set explicitly.
|
68
71
|
acme_scratch_dir = app.root / 'tmp' / 'acme'
|
69
72
|
acme_scratch_dir.mkpath
|
70
|
-
auto_cert_config.
|
73
|
+
auto_cert_config.cache_dir ||= (acme_scratch_dir / 'cache')
|
71
74
|
auto_cert_config.work_dir ||= (acme_scratch_dir / 'work')
|
72
75
|
|
73
76
|
auto_cert_config
|
@@ -16,19 +16,20 @@ module Anchor
|
|
16
16
|
class RenewalBusyWait
|
17
17
|
ONE_HOUR = 60 * 60
|
18
18
|
|
19
|
-
def self.wait_for_it(managed_certificate:, check_every: ONE_HOUR, &keep_going)
|
20
|
-
waiter = new(managed_certificate: managed_certificate, check_every: check_every)
|
19
|
+
def self.wait_for_it(manager:, managed_certificate:, check_every: ONE_HOUR, &keep_going)
|
20
|
+
waiter = new(manager: manager, managed_certificate: managed_certificate, check_every: check_every)
|
21
21
|
waiter.wait_for_it(&keep_going)
|
22
22
|
end
|
23
23
|
|
24
|
-
def initialize(managed_certificate:, check_every: ONE_HOUR)
|
24
|
+
def initialize(manager:, managed_certificate:, check_every: ONE_HOUR)
|
25
|
+
@manager = manager
|
25
26
|
@managed_certificate = managed_certificate
|
26
27
|
@check_every = check_every
|
27
28
|
end
|
28
29
|
|
29
30
|
def wait_for_it
|
30
31
|
loop do
|
31
|
-
break if @
|
32
|
+
break if @manager.needs_renewal?(cert: @managed_certificate)
|
32
33
|
break unless yield
|
33
34
|
|
34
35
|
sleep @check_every
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'pstore'
|
4
|
+
require 'tempfile'
|
5
|
+
|
6
|
+
module Anchor
|
7
|
+
# DiskStore is a simple key/value store that persists to disk using PStore.
|
8
|
+
#
|
9
|
+
class DiskStore
|
10
|
+
def initialize(dir: nil, basename: 'anchor-disk-store')
|
11
|
+
@dir = dir || Dir.mktmpdir
|
12
|
+
@basename = basename
|
13
|
+
@path = File.join(@dir, @basename)
|
14
|
+
@pstore = PStore.new(@path, true)
|
15
|
+
end
|
16
|
+
|
17
|
+
def [](key)
|
18
|
+
data = nil
|
19
|
+
@pstore.transaction do
|
20
|
+
data = @pstore[key]
|
21
|
+
end
|
22
|
+
data
|
23
|
+
end
|
24
|
+
|
25
|
+
def []=(key, value)
|
26
|
+
@pstore.transaction do
|
27
|
+
@pstore[key] = value
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Anchor
|
4
|
+
# PEMBundle is a collection of PEM encoded certificates. It can be written
|
5
|
+
# to a temporarly file on disk as a bundle if needed.
|
6
|
+
#
|
7
|
+
# This temp file to disk is needed for some other libraries that require a
|
8
|
+
# path to a pem file, and not a string of pem encoded certificates.
|
9
|
+
#
|
10
|
+
class PemBundle
|
11
|
+
DEFAULT_BASENAME = 'bundle.pem'
|
12
|
+
|
13
|
+
def initialize(pems: [])
|
14
|
+
@pems = pems || []
|
15
|
+
@path = nil
|
16
|
+
@temp_dir = nil
|
17
|
+
@basename = DEFAULT_BASENAME
|
18
|
+
end
|
19
|
+
|
20
|
+
def pems
|
21
|
+
@pems.dup
|
22
|
+
end
|
23
|
+
|
24
|
+
def path
|
25
|
+
write
|
26
|
+
@path
|
27
|
+
end
|
28
|
+
|
29
|
+
def with_path
|
30
|
+
yield(path)
|
31
|
+
ensure
|
32
|
+
remove_path
|
33
|
+
end
|
34
|
+
|
35
|
+
def add_cert(cert)
|
36
|
+
@pems << cert
|
37
|
+
remove_path
|
38
|
+
end
|
39
|
+
|
40
|
+
def to_s
|
41
|
+
@pems.join("\n")
|
42
|
+
end
|
43
|
+
|
44
|
+
def clear
|
45
|
+
@pems.clear
|
46
|
+
remove_path
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def temp_path
|
52
|
+
@temp_dir = Dir.mktmpdir
|
53
|
+
File.join(@temp_dir, @basename)
|
54
|
+
end
|
55
|
+
|
56
|
+
def ensure_path
|
57
|
+
return @path if @path
|
58
|
+
|
59
|
+
@path = temp_path
|
60
|
+
end
|
61
|
+
|
62
|
+
def write
|
63
|
+
remove_path
|
64
|
+
ensure_path
|
65
|
+
File.open(@path, 'w+', 0o400) do |io|
|
66
|
+
io.write(to_s)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def remove_path
|
71
|
+
File.unlink(@path) if @path && File.exist?(@path)
|
72
|
+
|
73
|
+
if @temp_dir && Dir.exist?(@temp_dir)
|
74
|
+
FileUtils.remove_entry_secure(@temp_dir)
|
75
|
+
@temp_dir = nil
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
data/lib/anchor/version.rb
CHANGED
data/lib/anchor.rb
CHANGED
@@ -13,7 +13,7 @@ module Puma
|
|
13
13
|
def ssl_bind_options(managed_certificate:)
|
14
14
|
{
|
15
15
|
cert: managed_certificate.cert_path,
|
16
|
-
key: managed_certificate.
|
16
|
+
key: managed_certificate.private_key_path
|
17
17
|
}
|
18
18
|
end
|
19
19
|
end
|
@@ -22,32 +22,34 @@ module Puma
|
|
22
22
|
# a plugin is created
|
23
23
|
module PluginInstanceMethods
|
24
24
|
attr_accessor :managed_certificate
|
25
|
-
attr_reader :port
|
25
|
+
attr_reader :manager, :port
|
26
26
|
|
27
27
|
def config(dsl)
|
28
28
|
@port = dsl.auto_cert_port || ENV.fetch('HTTPS_PORT', nil)
|
29
29
|
name = dsl.auto_cert_name || ENV.fetch('AUTO_CERT_NAME', 'default')
|
30
30
|
configuration = ::Anchor::AutoCert::Registry.fetch(name)
|
31
31
|
identifiers = configuration.allow_identifiers
|
32
|
-
manager
|
32
|
+
@manager = ::Anchor::AutoCert::Manager.new(configuration: configuration)
|
33
33
|
|
34
34
|
@managed_certificate = manager.managed_certificate(identifiers: identifiers)
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
dsl.ssl_bind '[::]', port, options
|
39
|
-
rescue StandardError
|
35
|
+
rescue StandardError => _e
|
36
|
+
@manager = nil
|
40
37
|
@managed_certificate = nil
|
41
38
|
end
|
42
39
|
|
43
40
|
def start(launcher)
|
44
41
|
@launcher = launcher
|
45
|
-
unless
|
42
|
+
unless manager&.enabled? && managed_certificate
|
46
43
|
log_writer.log 'AutoCert >> Not enabled - skipping certificate renewal process'
|
47
44
|
return
|
48
45
|
end
|
49
46
|
|
50
|
-
|
47
|
+
options = ::Puma::Plugin::AutoCert.ssl_bind_options(managed_certificate: managed_certificate)
|
48
|
+
launcher.config.configure do |_user_config, file_config|
|
49
|
+
file_config.ssl_bind '[::]', port, options
|
50
|
+
end
|
51
|
+
|
52
|
+
managed_certificate.identifiers.each do |identifier|
|
51
53
|
log_writer.log "AutoCert >> Available at https://#{identifier}:#{port}/"
|
52
54
|
end
|
53
55
|
|
@@ -56,7 +58,8 @@ module Puma
|
|
56
58
|
::Anchor::AutoCert::RenewalBusyWait::ONE_HOUR
|
57
59
|
|
58
60
|
in_background do
|
59
|
-
Anchor::AutoCert::RenewalBusyWait.wait_for_it(
|
61
|
+
Anchor::AutoCert::RenewalBusyWait.wait_for_it(manager: manager,
|
62
|
+
managed_certificate: managed_certificate,
|
60
63
|
check_every: check_every) do
|
61
64
|
dump_cert_info
|
62
65
|
|
@@ -71,6 +74,8 @@ module Puma
|
|
71
74
|
log_writer.log 'AutoCert >> Restarting Puma in order to renew certificate'
|
72
75
|
@launcher.restart
|
73
76
|
end
|
77
|
+
rescue StandardError => e
|
78
|
+
log_writer.log "AutoCert >> Error - #{e.message}"
|
74
79
|
end
|
75
80
|
|
76
81
|
private
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: anchor-pki
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.6.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Anchor Security, Inc
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-12-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: acme-client
|
@@ -24,6 +24,20 @@ dependencies:
|
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
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'
|
27
41
|
- !ruby/object:Gem::Dependency
|
28
42
|
name: minitest
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -94,6 +108,20 @@ dependencies:
|
|
94
108
|
- - "~>"
|
95
109
|
- !ruby/object:Gem::Version
|
96
110
|
version: '2.22'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: simplecov
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0.22'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0.22'
|
97
125
|
- !ruby/object:Gem::Dependency
|
98
126
|
name: vcr
|
99
127
|
requirement: !ruby/object:Gem::Requirement
|
@@ -152,7 +180,9 @@ files:
|
|
152
180
|
- lib/anchor/auto_cert/registry.rb
|
153
181
|
- lib/anchor/auto_cert/renewal_busy_wait.rb
|
154
182
|
- lib/anchor/auto_cert/terms_of_service_acceptor.rb
|
183
|
+
- lib/anchor/disk_store.rb
|
155
184
|
- lib/anchor/oid.rb
|
185
|
+
- lib/anchor/pem_bundle.rb
|
156
186
|
- lib/anchor/version.rb
|
157
187
|
- lib/puma/dsl.rb
|
158
188
|
- lib/puma/plugin/auto_cert.rb
|