Skip to content

Commit 2665666

Browse files
authored
Merge pull request #363 from OpenVoxProject/puppet_ssl_renew
Add a renew_cert subcommand to puppet ssl
2 parents 379b5fd + 2654372 commit 2665666

File tree

2 files changed

+127
-0
lines changed

2 files changed

+127
-0
lines changed

lib/puppet/application/ssl.rb

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ def help
4343
* --target CERTNAME
4444
Clean the specified device certificate instead of this host's certificate.
4545
46+
* --if-expiring-in DURATION
47+
When renewing a certificate only renew if the certificate is valid for
48+
less than this amount of time. Duration can be specified as a time
49+
interval, such as 30s, 5m, 1h.
50+
4651
ACTIONS
4752
-------
4853
@@ -71,6 +76,11 @@ def help
7176
for subsequent requests. If there is already an existing certificate, it
7277
will be overwritten.
7378
79+
* renew_cert
80+
Renew an existing and non-expired client certificate. When
81+
`--if-expiring-in` option is specified, then renew the certificate only
82+
if it's going to expire in the amount of time given.
83+
7484
* verify:
7585
Verify the private key and certificate are present and match, verify the
7686
certificate is issued by a trusted CA, and check revocation status.
@@ -98,6 +108,28 @@ def help
98108
option('--localca')
99109
option('--verbose', '-v')
100110
option('--debug', '-d')
111+
option('--if-expiring-in DURATION') do |arg|
112+
options[:expiring_in_sec] = parse_duration(arg)
113+
end
114+
115+
def parse_duration(value)
116+
unit_map = {
117+
"y" => 365 * 24 * 60 * 60,
118+
"d" => 24 * 60 * 60,
119+
"h" => 60 * 60,
120+
"m" => 60,
121+
"s" => 1
122+
}
123+
format = /^(\d+)(y|d|h|m|s)?$/
124+
125+
v = (value.is_a?(Integer) ? "#{value}s" : value)
126+
127+
if v =~ format
128+
Regexp.last_match(1).to_i * unit_map[::Regexp.last_match(2) || 's']
129+
else
130+
raise ArgumentError, "Invalid duration format: #{value}"
131+
end
132+
end
101133

102134
def initialize(command_line = Puppet::Util::CommandLine.new)
103135
super(command_line)
@@ -148,6 +180,8 @@ def main
148180
unless cert
149181
raise Puppet::Error, _("The certificate for '%{name}' has not yet been signed") % { name: certname }
150182
end
183+
when 'renew_cert'
184+
renew_cert(certname, options[:expiring_in_sec])
151185
when 'generate_request'
152186
generate_request(certname)
153187
when 'verify'
@@ -248,6 +282,37 @@ def download_cert(ssl_context)
248282
raise Puppet::Error.new(_("Failed to download certificate: %{message}") % { message: e.message }, e)
249283
end
250284

285+
def renew_cert(certname, expiring_in_sec_maybe)
286+
ssl_context = @ssl_provider.load_context(certname: certname)
287+
288+
if expiring_in_sec_maybe && (ssl_context[:client_cert].not_after - Time.now) > expiring_in_sec_maybe
289+
Puppet.info _("Certificate '%{name}' is still valid until %{date}") % { name: certname, date: ssl_context[:client_cert].not_after }
290+
return ssl_context[:client_cert]
291+
end
292+
293+
Puppet.debug _("Renewing certificate '%{name}'") % { name: certname }
294+
route = create_route(ssl_context)
295+
_, x509 = route.post_certificate_renewal(ssl_context)
296+
cert = OpenSSL::X509::Certificate.new(x509)
297+
Puppet.notice _("Downloaded certificate '%{name}' with fingerprint %{fingerprint}") % { name: certname, fingerprint: fingerprint(cert) }
298+
299+
# verify client cert before saving
300+
@ssl_provider.create_context(
301+
cacerts: ssl_context.cacerts, crls: ssl_context.crls, private_key: ssl_context.private_key, client_cert: cert
302+
)
303+
@cert_provider.save_client_cert(certname, cert)
304+
@cert_provider.delete_request(certname)
305+
cert
306+
rescue Puppet::HTTP::ResponseError => e
307+
if e.response.code == 404
308+
nil
309+
else
310+
raise Puppet::Error.new(_("Failed to download certificate: %{message}") % { message: e.message }, e)
311+
end
312+
rescue => e
313+
raise Puppet::Error.new(_("Failed to download certificate: %{message}") % { message: e.message }, e)
314+
end
315+
251316
def verify(certname)
252317
password = @cert_provider.load_private_key_password
253318
ssl_context = @ssl_provider.load_context(certname: certname, password: password)

spec/unit/application/ssl_spec.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,68 @@ def expects_command_to_fail(message)
262262
end
263263
end
264264

265+
context 'when renewing a certificate' do
266+
let(:renewed) do
267+
# Create a new cert with the same private key ("renew" an existing certificate)
268+
@ca.create_cert('ssl-client', @ca.ca_cert, @ca.key, reuse_key: @host[:private_key])
269+
end
270+
271+
before do
272+
ssl.command_line.args << 'renew_cert'
273+
# This command requires an existing certificate
274+
File.write(Puppet[:hostcert], @host[:cert].to_pem)
275+
end
276+
277+
it 'renews a new cert' do
278+
stub_request(:post, %r{puppet-ca/v1/certificate_renewal}).to_return(status: 200, body: renewed[:cert].to_pem)
279+
280+
expects_command_to_pass(%r{Downloaded certificate '#{name}' with fingerprint .*})
281+
282+
expect(File.read(Puppet[:hostcert])).to eq(renewed[:cert].to_pem)
283+
end
284+
285+
context 'with --if-expiring-in=100y specified' do
286+
before do
287+
ssl.command_line.args << '--if-expiring-in' << '100y'
288+
ssl.parse_options
289+
end
290+
291+
it 'renews a cert' do
292+
stub_request(:post, %r{puppet-ca/v1/certificate_renewal}).to_return(status: 200, body: renewed[:cert].to_pem)
293+
294+
expects_command_to_pass(%r{Downloaded certificate '#{name}' with fingerprint .*})
295+
296+
expect(File.read(Puppet[:hostcert])).to eq(renewed[:cert].to_pem)
297+
end
298+
end
299+
300+
context 'with --if-expiring-in=0 specified' do
301+
before do
302+
ssl.command_line.args << '--if-expiring-in' << '0y'
303+
ssl.parse_options
304+
end
305+
306+
it 'does not renew a cert' do
307+
expects_command_to_pass(%r{Certificate '#{name}' is still valid until .*})
308+
309+
expect(File.read(Puppet[:hostcert])).to eq(@host[:cert].to_pem)
310+
end
311+
end
312+
313+
it "reports an error if the downloaded cert's public key doesn't match our private key" do
314+
# generate a new host key, whose public key doesn't match the cert
315+
private_key = OpenSSL::PKey::RSA.new(512)
316+
File.write(Puppet[:hostprivkey], private_key.to_pem)
317+
File.write(Puppet[:hostpubkey], private_key.public_key.to_pem)
318+
319+
stub_request(:post, %r{puppet-ca/v1/certificate_renewal}).to_return(status: 200, body: renewed[:cert].to_pem)
320+
321+
expects_command_to_fail(
322+
%r{^Failed to download certificate: The certificate for 'CN=ssl-client' does not match its private key}
323+
)
324+
end
325+
end
326+
265327
context 'when verifying' do
266328
before do
267329
ssl.command_line.args << 'verify'

0 commit comments

Comments
 (0)