anchor-pki 0.5.0 → 0.6.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|