Skip to content

Commit fe0e6b5

Browse files
Merge pull request #9059 from joshcooper/renew_ca_cert_10639
(PUP-10639) Periodically refresh our CA bundle
2 parents 65a8348 + 7f01166 commit fe0e6b5

File tree

9 files changed

+296
-16
lines changed

9 files changed

+296
-16
lines changed

lib/puppet/defaults.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,24 @@ def self.initialize_default_settings!(settings)
12121212
:desc => "The default TTL for new certificates.
12131213
#{AS_DURATION}",
12141214
},
1215+
:ca_refresh_interval => {
1216+
:default => "1d",
1217+
:type => :duration,
1218+
:desc => "How often the Puppet agent refreshes its local CA certs. By
1219+
default the CA certs are refreshed once every 24 hours. If a different
1220+
duration is specified, then the agent will refresh its CA certs whenever
1221+
it next runs and the elapsed time since the certs were last refreshed
1222+
exceeds the duration.
1223+
1224+
In general, the duration should be greater than the `runinterval`.
1225+
Setting it to 0 or an equal or lesser value than `runinterval`,
1226+
will cause the CA certs to be refreshed on every run.
1227+
1228+
If the agent downloads new CA certs, the agent will use it for subsequent
1229+
network requests. If the refresh request fails or if the CA certs are
1230+
unchanged on the server, then the agent run will continue using the
1231+
local CA certs it already has. #{AS_DURATION}",
1232+
},
12151233
:crl_refresh_interval => {
12161234
:default => "1d",
12171235
:type => :duration,
@@ -1222,8 +1240,8 @@ def self.initialize_default_settings!(settings)
12221240
exceeds the duration.
12231241
12241242
In general, the duration should be greater than the `runinterval`.
1225-
Setting it to an equal or lesser value will cause the CRL to be
1226-
refreshed on every run.
1243+
Setting it to 0 or an equal or lesser value than `runinterval`,
1244+
will cause the CRL to be refreshed on every run.
12271245
12281246
If the agent downloads a new CRL, the agent will use it for subsequent
12291247
network requests. If the refresh request fails or if the CRL is

lib/puppet/http/service/ca.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,21 @@ def initialize(client, session, server, port)
2828
# Submit a GET request to retrieve the named certificate from the server.
2929
#
3030
# @param [String] name name of the certificate to request
31+
# @param [Time] if_modified_since If not nil, only download the cert if it has
32+
# been modified since the specified time.
3133
# @param [Puppet::SSL::SSLContext] ssl_context
3234
#
3335
# @return [Array<Puppet::HTTP::Response, String>] An array containing the
3436
# request response and the stringified body of the request response
3537
#
3638
# @api public
37-
def get_certificate(name, ssl_context: nil)
39+
def get_certificate(name, if_modified_since: nil, ssl_context: nil)
40+
headers = add_puppet_headers(HEADERS)
41+
headers['If-Modified-Since'] = if_modified_since.httpdate if if_modified_since
42+
3843
response = @client.get(
3944
with_base_url("/certificate/#{name}"),
40-
headers: add_puppet_headers(HEADERS),
45+
headers: headers,
4146
options: {ssl_context: ssl_context}
4247
)
4348

lib/puppet/ssl/state_machine.rb

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,28 @@ def initialize(machine)
5050
def next_state
5151
Puppet.debug("Loading CA certs")
5252

53+
force_crl_refresh = false
54+
5355
cacerts = @cert_provider.load_cacerts
5456
if cacerts
5557
next_ctx = @ssl_provider.create_root_context(cacerts: cacerts, revocation: false)
58+
59+
now = Time.now
60+
last_update = @cert_provider.ca_last_update
61+
if needs_refresh?(now, last_update)
62+
# set last updated time first, then make a best effort to refresh
63+
@cert_provider.ca_last_update = now
64+
65+
# If we refresh the CA, then we need to force the CRL to be refreshed too,
66+
# since if there is a new CA in the chain, then we need its CRL to check
67+
# the full chain for revocation status.
68+
next_ctx, force_crl_refresh = refresh_ca(next_ctx, last_update)
69+
end
5670
else
5771
route = @machine.session.route_to(:ca, ssl_context: @ssl_context)
5872
_, pem = route.get_certificate(Puppet::SSL::CA_NAME, ssl_context: @ssl_context)
5973
if @machine.ca_fingerprint
60-
actual_digest = Puppet::SSL::Digest.new(@machine.digest, pem).to_hex
74+
actual_digest = @machine.digest_as_hex(pem)
6175
expected_digest = @machine.ca_fingerprint.scan(/../).join(':').upcase
6276
if actual_digest == expected_digest
6377
Puppet.info(_("Verified CA bundle with digest (%{digest_type}) %{actual_digest}") %
@@ -74,7 +88,7 @@ def next_state
7488
@cert_provider.save_cacerts(cacerts)
7589
end
7690

77-
NeedCRLs.new(@machine, next_ctx)
91+
NeedCRLs.new(@machine, next_ctx, force_crl_refresh)
7892
rescue OpenSSL::X509::CertificateError => e
7993
Error.new(@machine, e.message, e)
8094
rescue Puppet::HTTP::ResponseError => e
@@ -84,6 +98,51 @@ def next_state
8498
to_error(_('Could not download CA certificate: %{message}') % { message: e.message }, e)
8599
end
86100
end
101+
102+
private
103+
104+
def needs_refresh?(now, last_update)
105+
return true if last_update.nil?
106+
107+
ca_ttl = Puppet[:ca_refresh_interval]
108+
return false unless ca_ttl
109+
110+
now.to_i > last_update.to_i + ca_ttl
111+
end
112+
113+
def refresh_ca(ssl_ctx, last_update)
114+
Puppet.info(_("Refreshing CA certificate"))
115+
116+
# return the next_ctx containing the updated ca
117+
[download_ca(ssl_ctx, last_update), true]
118+
rescue Puppet::HTTP::ResponseError => e
119+
if e.response.code == 304
120+
Puppet.info(_("CA certificate is unmodified, using existing CA certificate"))
121+
else
122+
Puppet.info(_("Failed to refresh CA certificate, using existing CA certificate: %{message}") % {message: e.message})
123+
end
124+
125+
# return the original ssl_ctx
126+
[ssl_ctx, false]
127+
rescue Puppet::HTTP::HTTPError => e
128+
Puppet.warning(_("Failed to refresh CA certificate, using existing CA certificate: %{message}") % {message: e.message})
129+
130+
# return the original ssl_ctx
131+
[ssl_ctx, false]
132+
end
133+
134+
def download_ca(ssl_ctx, last_update)
135+
route = @machine.session.route_to(:ca, ssl_context: ssl_ctx)
136+
_, pem = route.get_certificate(Puppet::SSL::CA_NAME, if_modified_since: last_update, ssl_context: ssl_ctx)
137+
cacerts = @cert_provider.load_cacerts_from_pem(pem)
138+
# verify cacerts before saving
139+
next_ctx = @ssl_provider.create_root_context(cacerts: cacerts, revocation: false)
140+
@cert_provider.save_cacerts(cacerts)
141+
142+
Puppet.info("Refreshed CA certificate: #{@machine.digest_as_hex(pem)}")
143+
144+
next_ctx
145+
end
87146
end
88147

89148
# If revocation is enabled, load CRLs or download them, using the CA bundle
@@ -93,6 +152,13 @@ def next_state
93152
# for which we don't have a CRL
94153
#
95154
class NeedCRLs < SSLState
155+
attr_reader :force_crl_refresh
156+
157+
def initialize(machine, ssl_context, force_crl_refresh = false)
158+
super(machine, ssl_context)
159+
@force_crl_refresh = force_crl_refresh
160+
end
161+
96162
def next_state
97163
Puppet.debug("Loading CRLs")
98164

@@ -102,15 +168,12 @@ def next_state
102168
if crls
103169
next_ctx = @ssl_provider.create_root_context(cacerts: ssl_context[:cacerts], crls: crls)
104170

105-
crl_ttl = Puppet[:crl_refresh_interval]
106-
if crl_ttl
107-
last_update = @cert_provider.crl_last_update
108-
now = Time.now
109-
if last_update.nil? || now.to_i > last_update.to_i + crl_ttl
110-
# set last updated time first, then make a best effort to refresh
111-
@cert_provider.crl_last_update = now
112-
next_ctx = refresh_crl(next_ctx, last_update)
113-
end
171+
now = Time.now
172+
last_update = @cert_provider.crl_last_update
173+
if needs_refresh?(now, last_update)
174+
# set last updated time first, then make a best effort to refresh
175+
@cert_provider.crl_last_update = now
176+
next_ctx = refresh_crl(next_ctx, last_update)
114177
end
115178
else
116179
next_ctx = download_crl(@ssl_context, nil)
@@ -133,6 +196,15 @@ def next_state
133196

134197
private
135198

199+
def needs_refresh?(now, last_update)
200+
return true if @force_crl_refresh || last_update.nil?
201+
202+
crl_ttl = Puppet[:crl_refresh_interval]
203+
return false unless crl_ttl
204+
205+
now.to_i > last_update.to_i + crl_ttl
206+
end
207+
136208
def refresh_crl(ssl_ctx, last_update)
137209
Puppet.info(_("Refreshing CRL"))
138210

@@ -162,6 +234,8 @@ def download_crl(ssl_ctx, last_update)
162234
next_ctx = @ssl_provider.create_root_context(cacerts: ssl_ctx[:cacerts], crls: crls)
163235
@cert_provider.save_crls(crls)
164236

237+
Puppet.info("Refreshed CRL: #{@machine.digest_as_hex(pem)}")
238+
165239
next_ctx
166240
end
167241
end
@@ -441,6 +515,10 @@ def unlock
441515
@lockfile.unlock
442516
end
443517

518+
def digest_as_hex(str)
519+
Puppet::SSL::Digest.new(digest, str).to_hex
520+
end
521+
444522
private
445523

446524
def run_machine(state, stop)

lib/puppet/x509/cert_provider.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,28 @@ def crl_last_update=(time)
147147
Puppet::FileSystem.touch(@crlpath, mtime: time)
148148
end
149149

150+
# Return the time when the CA bundle was last updated.
151+
#
152+
# @return [Time, nil] Time when the CA bundle was last updated, or nil if we don't
153+
# have a CA bundle
154+
#
155+
# @api private
156+
def ca_last_update
157+
stat = Puppet::FileSystem.stat(@capath)
158+
Time.at(stat.mtime)
159+
rescue Errno::ENOENT
160+
nil
161+
end
162+
163+
# Set the CA bundle last updated time.
164+
#
165+
# @param time [Time] The last updated time
166+
#
167+
# @api private
168+
def ca_last_update=(time)
169+
Puppet::FileSystem.touch(@capath, mtime: time)
170+
end
171+
150172
# Save named private key in the configured `privatekeydir`. For
151173
# historical reasons, names are case insensitive.
152174
#

spec/integration/application/agent_spec.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,55 @@ def copy_fixtures(sources, dest)
896896
.and output(%r{Certificate 'CN=revoked' is revoked}).to_stderr
897897
end
898898
end
899+
900+
it "refreshes the CA and CRL" do
901+
Puppet[:localcacert] = ca = tmpfile('ca')
902+
Puppet[:hostcrl] = crl = tmpfile('crl')
903+
copy_fixtures(%w[ca.pem intermediate.pem], ca)
904+
copy_fixtures(%w[crl.pem intermediate-crl.pem], crl)
905+
906+
now = Time.now
907+
yesterday = now - (60 * 60 * 24)
908+
Puppet::FileSystem.touch(ca, mtime: yesterday)
909+
Puppet::FileSystem.touch(crl, mtime: yesterday)
910+
911+
server.start_server do |port|
912+
Puppet[:serverport] = port
913+
Puppet[:ca_refresh_interval] = 1
914+
915+
expect {
916+
agent.command_line.args << '--test'
917+
agent.run
918+
}.to exit_with(0)
919+
.and output(/Info: Refreshed CA certificate: /).to_stdout
920+
end
921+
922+
# If the CA is updated, then the CRL must be updated too
923+
expect(Puppet::FileSystem.stat(ca).mtime).to be >= now
924+
expect(Puppet::FileSystem.stat(crl).mtime).to be >= now
925+
end
926+
927+
it "refreshes only the CRL" do
928+
Puppet[:hostcrl] = crl = tmpfile('crl')
929+
copy_fixtures(%w[crl.pem intermediate-crl.pem], crl)
930+
931+
now = Time.now
932+
yesterday = now - (60 * 60 * 24)
933+
Puppet::FileSystem.touch(crl, mtime: yesterday)
934+
935+
server.start_server do |port|
936+
Puppet[:serverport] = port
937+
Puppet[:crl_refresh_interval] = 1
938+
939+
expect {
940+
agent.command_line.args << '--test'
941+
agent.run
942+
}.to exit_with(0)
943+
.and output(/Info: Refreshed CRL: /).to_stdout
944+
end
945+
946+
expect(Puppet::FileSystem.stat(crl).mtime).to be >= now
947+
end
899948
end
900949

901950
context "legacy facts" do
@@ -994,6 +1043,7 @@ def mounts
9941043
expect {
9951044
agent.run
9961045
}.to exit_with(1)
1046+
.and output(/Info: Loading facts/).to_stdout
9971047
.and output(
9981048
match(/Error: Evaluation Error: Unknown variable: 'osfamily'/)
9991049
.and match(/Error: Could not retrieve catalog from remote server: Error 500 on SERVER:/)

spec/unit/application/lookup_spec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,7 @@ def run_lookup(lookup)
668668
expect {
669669
lookup.run_command
670670
}.to exit_with(0)
671+
.and output(/This is in facts hash/).to_stdout
671672
end
672673
end
673674
end

spec/unit/http/service/ca_spec.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,18 @@
9595
expect(err.response.code).to eq(404)
9696
end
9797
end
98+
99+
it 'raises a 304 response error if it is unmodified' do
100+
stub_request(:get, url).to_return(status: [304, 'Not Modified'])
101+
102+
expect {
103+
subject.get_certificate('ca', if_modified_since: Time.now)
104+
}.to raise_error do |err|
105+
expect(err).to be_an_instance_of(Puppet::HTTP::ResponseError)
106+
expect(err.message).to eq("Not Modified")
107+
expect(err.response.code).to eq(304)
108+
end
109+
end
98110
end
99111

100112
context 'when getting CRLs' do

0 commit comments

Comments
 (0)