anchor-pki 0.5.0 → 0.6.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 +4 -4
- data/Gemfile.lock +15 -3
- data/README.md +2 -1
- data/lib/anchor/auto_cert/configuration.rb +102 -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 +85 -37
- data/lib/anchor/auto_cert/railtie.rb +1 -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 +9 -7
- 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: 2fe888d3160e743df15731de957658f18c7a31a1805d29d6d95d1364c03ceb73
|
4
|
+
data.tar.gz: d264ec5e53c951ceb0c8a5214479a9dd36933f220e05671f88b2029c418f00a3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7dafa50e3a537fe79c29fedb256e9a4986feceb78cf81cd0afb4c30b14eacee84fdd5c952ed7e34866b96711d4d4f390b00ba91ef3ff0e7c8abab3b4018b1858
|
7
|
+
data.tar.gz: d9315cdc04dd7dd45dbdb223a31e392113c909f56a42d94981222196c68b90adea48d142847fa2b246655527a9c3f6cf18a53e26f0f7b9e5f6ec930d8dbd8363
|
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.5.0)
|
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.11)
|
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,6 +32,7 @@ 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
38
|
rake (13.0.6)
|
@@ -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)
|
@@ -87,6 +98,7 @@ DEPENDENCIES
|
|
87
98
|
rspec (~> 3.9)
|
88
99
|
rubocop (~> 1.50)
|
89
100
|
rubocop-rspec (~> 2.22)
|
101
|
+
simplecov (~> 0.22)
|
90
102
|
vcr (~> 6.1)
|
91
103
|
webmock (~> 3.8)
|
92
104
|
|
data/README.md
CHANGED
@@ -58,7 +58,8 @@ regenerated periodically.
|
|
58
58
|
export ACME_DIRECTORY_URL='https://anchor.dev/autocert-cab3bc/development/x509/ca/acme'
|
59
59
|
export ACME_KID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
60
60
|
export ACME_HMAC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
61
|
-
|
61
|
+
1. Update the [./spec/spec_helper.rb](spec/spec_helper.rb) file with these
|
62
|
+
values as the respective `VCR_KID` and `VCR_HMAC_KEY`.
|
62
63
|
1. on the command line execute:
|
63
64
|
|
64
65
|
$ . .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,31 @@ 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)
|
74
79
|
@directory_url = prepare_directory_url(@directory_url)
|
75
80
|
@external_account_binding = prepare_external_account_binding(@external_account_binding)
|
76
81
|
@renew_before_fraction = prepare_renew_before_fraction(@renew_before_fraction)
|
77
82
|
@renew_before_seconds = prepare_renew_before_seconds(@renew_before_seconds)
|
78
83
|
@tos_acceptors = prepare_tos_acceptors(@tos_acceptors)
|
79
|
-
@work_dir =
|
80
|
-
|
84
|
+
@work_dir = prepare_directory(dir: @work_dir, property: 'work_dir')
|
81
85
|
self
|
82
86
|
end
|
83
87
|
|
88
|
+
# Return the fallback identifer for this configuration
|
89
|
+
|
90
|
+
# look at all the identifiers, strip a leading wildcard off of all of
|
91
|
+
# them and then pick the one that has the fewest '.' in it, if there are
|
92
|
+
# ties for fewest, pick the first one in the list of ties. A minimum of
|
93
|
+
# 2 '.' is required.
|
94
|
+
#
|
95
|
+
def fallback_identifier
|
96
|
+
de_wildcarded = allow_identifiers.map { |i| i.sub(/^\*\./, '') }
|
97
|
+
not_tld = de_wildcarded.select { |i| i.count('.') >= 2 }
|
98
|
+
ordered = not_tld.sort_by { |i| i.count('.') }
|
99
|
+
ordered[0]
|
100
|
+
end
|
101
|
+
|
84
102
|
private
|
85
103
|
|
86
104
|
def prepare_allow_identifiers(allow_identifiers)
|
@@ -95,33 +113,36 @@ module Anchor
|
|
95
113
|
|
96
114
|
if prepared.nil? || prepared.empty?
|
97
115
|
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
|
116
|
+
"The '#{name}' #{self.class} instance has a misconfigured " \
|
117
|
+
'`allow_identifiers` value. Set it to a string, or an array of strings, ' \
|
118
|
+
'or set the ACME_ALLOW_IDENTIFIERS environment variable ' \
|
119
|
+
'to a comma separated list of identifiers.'
|
101
120
|
end
|
102
121
|
|
103
122
|
prepared
|
104
123
|
end
|
105
124
|
|
106
|
-
def
|
107
|
-
|
125
|
+
def prepare_check_every_seconds(check_every_seconds)
|
126
|
+
message = "The '#{name}' #{self.class} instance has a misconfigured " \
|
127
|
+
'`check_every_seconds` value. It must be set to an integer > 0, ' \
|
128
|
+
'or set the AUTO_CERT_CHECK_EVERY environment variable.'
|
108
129
|
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
130
|
+
candidates = [
|
131
|
+
check_every_seconds,
|
132
|
+
ENV.fetch('AUTO_CERT_CHECK_EVERY', nil),
|
133
|
+
DEFAULT_CHECK_EVERY_SECONDS
|
134
|
+
]
|
114
135
|
|
115
|
-
|
136
|
+
ensure_positive_integer(candidates, message)
|
116
137
|
end
|
117
138
|
|
118
139
|
def prepare_directory_url(directory_url)
|
140
|
+
message = "The '#{name}' #{self.class} instance has a misconfigured `directory_url` value. " \
|
141
|
+
'It must be set to a string, or set the ACME_DIRECTORY_URL environment variable.'
|
142
|
+
|
119
143
|
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
|
144
|
+
|
145
|
+
raise ConfigurationError, message if directory_url.nil?
|
125
146
|
|
126
147
|
directory_url
|
127
148
|
end
|
@@ -130,39 +151,47 @@ module Anchor
|
|
130
151
|
kid = ENV.fetch('ACME_KID', nil)
|
131
152
|
hmac_key = ENV.fetch('ACME_HMAC_KEY', nil)
|
132
153
|
|
133
|
-
if kid && hmac_key
|
134
|
-
external_account_binding
|
135
|
-
kid: kid,
|
136
|
-
hmac_key: hmac_key
|
137
|
-
}
|
154
|
+
if external_account_binding && external_account_binding[:kid] && external_account_binding[:hmac_key]
|
155
|
+
return external_account_binding
|
138
156
|
end
|
139
|
-
|
157
|
+
|
158
|
+
{ kid: kid, hmac_key: hmac_key }
|
140
159
|
end
|
141
160
|
|
142
161
|
def prepare_renew_before_seconds(renew_before_seconds)
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
162
|
+
message = "The '#{name}' #{self.class} instance has a misconfigured " \
|
163
|
+
'`before_seconds` value. It must be set to an integer > 0, ' \
|
164
|
+
'or set the ACME_RENEW_BEFORE_SECONDS environment variable.'
|
165
|
+
|
166
|
+
candidates = [
|
167
|
+
renew_before_seconds,
|
168
|
+
ENV.fetch('ACME_RENEW_BEFORE_SECONDS', nil),
|
169
|
+
DEFAULT_RENEW_BEFORE_SECONDS
|
170
|
+
]
|
171
|
+
ensure_positive_integer(candidates, message)
|
153
172
|
end
|
154
173
|
|
155
174
|
def prepare_renew_before_fraction(renew_before_fraction)
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
175
|
+
message = "The '#{name}' #{self.class} instance has a misconfigured " \
|
176
|
+
'`before_fraction` value. It must be set to a float > 0 and < 1, ' \
|
177
|
+
'or set the ACME_RENEW_BEFORE_FRACTION environment variable.'
|
178
|
+
|
179
|
+
candidates = [
|
180
|
+
renew_before_fraction,
|
181
|
+
ENV.fetch('ACME_RENEW_BEFORE_FRACTION', nil),
|
182
|
+
DEFAULT_RENEW_BEFORE_FRACTION
|
183
|
+
]
|
184
|
+
|
185
|
+
candidates.each do |candidate|
|
186
|
+
next if candidate.nil?
|
187
|
+
|
188
|
+
as_float = candidate.to_f
|
189
|
+
return as_float if (0..1).cover?(as_float)
|
164
190
|
end
|
165
|
-
|
191
|
+
|
192
|
+
# this should really never happen as DEFAULT_RENEW_BEFORE_FRACTION is
|
193
|
+
# valid
|
194
|
+
raise ConfigurationError, message
|
166
195
|
end
|
167
196
|
|
168
197
|
def prepare_tos_acceptors(tos_acceptors)
|
@@ -170,29 +199,40 @@ module Anchor
|
|
170
199
|
|
171
200
|
if tos_acceptors.empty? || tos_acceptors.any? { |tos| !tos.respond_to?(:accept?) }
|
172
201
|
raise ConfigurationError,
|
173
|
-
"The '#{name}' #{self.class} instance has a misconfigured
|
174
|
-
'It must be set to an object
|
202
|
+
"The '#{name}' #{self.class} instance has a misconfigured " \
|
203
|
+
'`tos_acceptors` value. It must be set to an object ' \
|
204
|
+
'or an array of objects that respond to `accept?`.'
|
175
205
|
end
|
176
206
|
|
177
207
|
tos_acceptors
|
178
208
|
end
|
179
209
|
|
180
|
-
def
|
181
|
-
return nil if
|
210
|
+
def prepare_directory(dir:, property:)
|
211
|
+
return nil if dir.nil?
|
182
212
|
|
183
|
-
|
213
|
+
dir = Pathname.new(dir) unless dir.is_a?(Pathname)
|
214
|
+
message = "The '#{name}' #{self.class} instance has a misconfigured " \
|
215
|
+
"`#{property}` value, it resolves to (#{dir}). " \
|
216
|
+
'It must be set to a directory, or a path that can be created.'
|
184
217
|
|
185
218
|
begin
|
186
|
-
|
187
|
-
rescue StandardError =>
|
188
|
-
raise ConfigurationError,
|
219
|
+
dir.mkpath
|
220
|
+
rescue StandardError => _e
|
221
|
+
raise ConfigurationError, message
|
189
222
|
end
|
190
223
|
|
191
|
-
|
192
|
-
|
224
|
+
dir
|
225
|
+
end
|
226
|
+
|
227
|
+
def ensure_positive_integer(candidates, message)
|
228
|
+
candidates.each do |candidate|
|
229
|
+
next if candidate.nil?
|
230
|
+
|
231
|
+
as_int = candidate.to_i
|
232
|
+
return as_int if as_int.positive?
|
193
233
|
end
|
194
234
|
|
195
|
-
|
235
|
+
raise ConfigurationError, message
|
196
236
|
end
|
197
237
|
end
|
198
238
|
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,59 @@ 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
|
+
return managed_certificate if managed_certificate && !needs_renewal?(cert: managed_certificate, now: now)
|
77
|
+
|
78
|
+
# then look into the disk cache
|
79
|
+
if @disk_store
|
80
|
+
key_pem = @disk_store["#{common_name}.key.pem"]
|
81
|
+
cert_pem = @disk_store["#{common_name}.cert.pem"]
|
82
|
+
end
|
64
83
|
|
65
84
|
if !key_pem.nil? && !cert_pem.nil?
|
66
|
-
|
67
|
-
|
85
|
+
managed_certificate = ManagedCertificate.new(cert_pem: cert_pem,
|
86
|
+
key_pem: key_pem,
|
87
|
+
persist_dir: work_dir)
|
88
|
+
if managed_certificate && !needs_renewal?(cert: managed_certificate, now: now)
|
89
|
+
return managed_certificate
|
90
|
+
end
|
68
91
|
end
|
69
92
|
|
70
|
-
|
93
|
+
# and then provision a new one
|
94
|
+
cert_pem, key_pem = provision_or_fallback(
|
95
|
+
identifiers: identifiers, algorithm: algorithm,
|
96
|
+
common_name: common_name,
|
97
|
+
**opts
|
98
|
+
)
|
71
99
|
|
72
|
-
|
73
|
-
cache&.write(common_name, cert_pem)
|
100
|
+
managed_certificate = ManagedCertificate.new(cert_pem: cert_pem, key_pem: key_pem, persist_dir: work_dir)
|
74
101
|
|
75
|
-
|
102
|
+
@managed_certificates[common_name] = managed_certificate
|
103
|
+
|
104
|
+
if @disk_store
|
105
|
+
@disk_store["#{common_name}.key.pem"] = key_pem
|
106
|
+
@disk_store["#{common_name}.cert.pem"] = cert_pem
|
107
|
+
end
|
108
|
+
|
109
|
+
managed_certificate
|
76
110
|
end
|
77
111
|
|
78
112
|
def needs_renewal?(cert:, now: Time.now.utc)
|
@@ -97,12 +131,25 @@ module Anchor
|
|
97
131
|
|
98
132
|
private
|
99
133
|
|
134
|
+
def provision_or_fallback(identifiers:, algorithm:, common_name:, **opts)
|
135
|
+
cert_pem = nil
|
136
|
+
key_pem = nil
|
137
|
+
begin
|
138
|
+
cert_pem, key_pem = provision(identifiers: identifiers, algorithm: algorithm, common_name: common_name,
|
139
|
+
**opts)
|
140
|
+
rescue StandardError => _e
|
141
|
+
cert_pem, key_pem = provision(identifiers: [], algorithm: algorithm, common_name: fallback_identifier,
|
142
|
+
**opts)
|
143
|
+
end
|
144
|
+
[cert_pem, key_pem]
|
145
|
+
end
|
146
|
+
|
100
147
|
def provision(identifiers:, algorithm:, common_name:, **opts)
|
101
|
-
identifiers =
|
148
|
+
identifiers = consolidate_identifiers(common_name: common_name, identifiers: identifiers)
|
102
149
|
load_or_build_account
|
103
150
|
key_pem ||= new_key(algorithm).to_pem
|
104
|
-
csr = Acme::Client::CertificateRequest.new(
|
105
|
-
|
151
|
+
csr = Acme::Client::CertificateRequest.new(common_name: common_name, names: identifiers,
|
152
|
+
private_key: parse_key_pem(key_pem))
|
106
153
|
|
107
154
|
order = @client.new_order(identifiers: identifiers, **opts)
|
108
155
|
order.finalize(csr: csr)
|
@@ -154,12 +201,13 @@ module Anchor
|
|
154
201
|
end
|
155
202
|
|
156
203
|
def new_client(account_key: nil, contact: nil, **)
|
157
|
-
account_key ||=
|
204
|
+
account_key ||= account_key_for(contact)
|
158
205
|
|
159
206
|
Acme::Client.new(private_key: account_key, directory: @directory_url)
|
160
207
|
end
|
161
208
|
|
162
|
-
|
209
|
+
# currently only using ecdsa algorithm
|
210
|
+
def new_key(algorithm = :ecdsa)
|
163
211
|
case algorithm
|
164
212
|
when :ecdsa then OpenSSL::PKey::EC.generate('prime256v1')
|
165
213
|
else
|
@@ -167,25 +215,19 @@ module Anchor
|
|
167
215
|
end
|
168
216
|
end
|
169
217
|
|
170
|
-
def
|
171
|
-
|
172
|
-
parse_key_pem(cache&.fetch(id) { parse_key_pem(yield.to_pem) } || yield.to_pem)
|
173
|
-
end
|
218
|
+
def account_key_for(contact)
|
219
|
+
return new_key unless @disk_store
|
174
220
|
|
175
|
-
|
176
|
-
|
177
|
-
|
221
|
+
account_key_id = "#{contact || 'default'}+#{@directory_url}+key"
|
222
|
+
pem = @disk_store[account_key_id]
|
223
|
+
return parse_key_pem(pem) if pem
|
178
224
|
|
179
|
-
|
225
|
+
raw_key = new_key
|
226
|
+
@disk_store[account_key_id] = raw_key.to_pem
|
227
|
+
raw_key
|
180
228
|
end
|
181
229
|
|
182
|
-
def
|
183
|
-
OpenSSL::PKey::RSA.new(data)
|
184
|
-
rescue StandardError
|
185
|
-
nil
|
186
|
-
end
|
187
|
-
|
188
|
-
def parse_ecdsa_pem(data)
|
230
|
+
def parse_key_pem(data)
|
189
231
|
OpenSSL::PKey::EC.new(data)
|
190
232
|
rescue StandardError
|
191
233
|
nil
|
@@ -197,6 +239,12 @@ module Anchor
|
|
197
239
|
@identifier_policies.any? { |policy| policy.allow?(identifier) }
|
198
240
|
end
|
199
241
|
end
|
242
|
+
|
243
|
+
# return a list of identifiers with duplicates removed
|
244
|
+
# preserving order with the common_name first
|
245
|
+
def consolidate_identifiers(common_name:, identifiers: [])
|
246
|
+
[common_name, *identifiers].compact.uniq
|
247
|
+
end
|
200
248
|
end
|
201
249
|
end
|
202
250
|
end
|
@@ -67,7 +67,7 @@ module Anchor
|
|
67
67
|
# set explicitly.
|
68
68
|
acme_scratch_dir = app.root / 'tmp' / 'acme'
|
69
69
|
acme_scratch_dir.mkpath
|
70
|
-
auto_cert_config.
|
70
|
+
auto_cert_config.cache_dir ||= (acme_scratch_dir / 'cache')
|
71
71
|
auto_cert_config.work_dir ||= (acme_scratch_dir / 'work')
|
72
72
|
|
73
73
|
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,33 @@ 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
35
|
|
36
36
|
options = ::Puma::Plugin::AutoCert.ssl_bind_options(managed_certificate: @managed_certificate)
|
37
37
|
|
38
38
|
dsl.ssl_bind '[::]', port, options
|
39
|
-
rescue StandardError
|
39
|
+
rescue StandardError => _e
|
40
|
+
@manager = nil
|
40
41
|
@managed_certificate = nil
|
41
42
|
end
|
42
43
|
|
43
44
|
def start(launcher)
|
44
45
|
@launcher = launcher
|
45
|
-
unless
|
46
|
+
unless manager&.enabled? && managed_certificate
|
46
47
|
log_writer.log 'AutoCert >> Not enabled - skipping certificate renewal process'
|
47
48
|
return
|
48
49
|
end
|
49
50
|
|
50
|
-
|
51
|
+
managed_certificate.identifiers.each do |identifier|
|
51
52
|
log_writer.log "AutoCert >> Available at https://#{identifier}:#{port}/"
|
52
53
|
end
|
53
54
|
|
@@ -56,7 +57,8 @@ module Puma
|
|
56
57
|
::Anchor::AutoCert::RenewalBusyWait::ONE_HOUR
|
57
58
|
|
58
59
|
in_background do
|
59
|
-
Anchor::AutoCert::RenewalBusyWait.wait_for_it(
|
60
|
+
Anchor::AutoCert::RenewalBusyWait.wait_for_it(manager: manager,
|
61
|
+
managed_certificate: managed_certificate,
|
60
62
|
check_every: check_every) do
|
61
63
|
dump_cert_info
|
62
64
|
|
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.0
|
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-11-29 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
|