diff --git a/lib/puppet/application/ca.rb b/lib/puppet/application/ca.rb new file mode 100644 index 000000000..d1ec2502e --- /dev/null +++ b/lib/puppet/application/ca.rb @@ -0,0 +1,5 @@ +require 'puppet/application/face_base' + +class Puppet::Application::Ca < Puppet::Application::FaceBase + run_mode :master +end diff --git a/lib/puppet/face/ca.rb b/lib/puppet/face/ca.rb new file mode 100644 index 000000000..e643530f0 --- /dev/null +++ b/lib/puppet/face/ca.rb @@ -0,0 +1,233 @@ +require 'puppet/face' + +Puppet::Face.define(:ca, '0.1.0') do + copyright "Puppet Labs", 2011 + license "Apache 2 license; see COPYING" + + summary "Local Puppet Certificate Authority management." + + description < e + "- #{name} (#{host.certificate.fingerprint}) (#{e.to_s})" + end + end + end.join("\n") + end + end + + action :destroy do + when_invoked do |host, options| + raise "Not a CA" unless Puppet::SSL::CertificateAuthority.ca? + unless ca = Puppet::SSL::CertificateAuthority.instance + raise "Unable to fetch the CA" + end + + ca.destroy host + end + end + + action :revoke do + when_invoked do |host, options| + raise "Not a CA" unless Puppet::SSL::CertificateAuthority.ca? + unless ca = Puppet::SSL::CertificateAuthority.instance + raise "Unable to fetch the CA" + end + + begin + ca.revoke host + rescue ArgumentError => e + # This is a bit naff, but it makes the behaviour consistent with the + # destroy action. The underlying tools could be nicer for that sort + # of thing; they have fairly inconsistent reporting of failures. + raise unless e.to_s =~ /Could not find a serial number for / + "Nothing was revoked" + end + end + end + + action :generate do + when_invoked do |host, options| + raise "Not a CA" unless Puppet::SSL::CertificateAuthority.ca? + unless ca = Puppet::SSL::CertificateAuthority.instance + raise "Unable to fetch the CA" + end + + begin + ca.generate host + rescue RuntimeError => e + if e.to_s =~ /already has a requested certificate/ + "#{host} already has a certificate request; use sign instead" + else + raise + end + rescue ArgumentError => e + if e.to_s =~ /A Certificate already exists for / + "#{host} already has a certificate" + else + raise + end + end + end + end + + action :sign do + when_invoked do |host, options| + raise "Not a CA" unless Puppet::SSL::CertificateAuthority.ca? + unless ca = Puppet::SSL::CertificateAuthority.instance + raise "Unable to fetch the CA" + end + + begin + ca.sign host + rescue ArgumentError => e + if e.to_s =~ /Could not find certificate request/ + e.to_s + else + raise + end + end + end + end + + action :print do + when_invoked do |host, options| + raise "Not a CA" unless Puppet::SSL::CertificateAuthority.ca? + unless ca = Puppet::SSL::CertificateAuthority.instance + raise "Unable to fetch the CA" + end + + ca.print host + end + end + + action :fingerprint do + option "--digest ALGORITHM" do + summary "The hash algorithm to use when displaying the fingerprint" + end + + when_invoked do |host, options| + raise "Not a CA" unless Puppet::SSL::CertificateAuthority.ca? + unless ca = Puppet::SSL::CertificateAuthority.instance + raise "Unable to fetch the CA" + end + + begin + # I want the default from the CA, not to duplicate it, but passing + # 'nil' explicitly means that we don't get that. This works... + if options.has_key? :digest + ca.fingerprint host, options[:digest] + else + ca.fingerprint host + end + rescue ArgumentError => e + raise unless e.to_s =~ /Could not find a certificate or csr for/ + nil + end + end + end + + action :verify do + when_invoked do |host, options| + raise "Not a CA" unless Puppet::SSL::CertificateAuthority.ca? + unless ca = Puppet::SSL::CertificateAuthority.instance + raise "Unable to fetch the CA" + end + + begin + ca.verify host + { :host => host, :valid => true } + rescue ArgumentError => e + raise unless e.to_s =~ /Could not find a certificate for/ + { :host => host, :valid => false, :error => e.to_s } + rescue Puppet::SSL::CertificateAuthority::CertificateVerificationError => e + { :host => host, :valid => false, :error => e.to_s } + end + end + + when_rendering :console do |value| + if value[:valid] + nil + else + "Could not verify #{value[:host]}: #{value[:error]}" + end + end + end +end diff --git a/lib/puppet/ssl/inventory.rb b/lib/puppet/ssl/inventory.rb index e094da100..c210fdc35 100644 --- a/lib/puppet/ssl/inventory.rb +++ b/lib/puppet/ssl/inventory.rb @@ -1,52 +1,54 @@ require 'puppet/ssl' require 'puppet/ssl/certificate' # Keep track of all of our known certificates. class Puppet::SSL::Inventory attr_reader :path # Add a certificate to our inventory. def add(cert) cert = cert.content if cert.is_a?(Puppet::SSL::Certificate) # Create our file, if one does not already exist. rebuild unless FileTest.exist?(@path) Puppet.settings.write(:cert_inventory, "a") do |f| f.print format(cert) end end # Format our certificate for output. def format(cert) iso = '%Y-%m-%dT%H:%M:%S%Z' "0x%04x %s %s %s\n" % [cert.serial, cert.not_before.strftime(iso), cert.not_after.strftime(iso), cert.subject] end def initialize @path = Puppet[:cert_inventory] end # Rebuild the inventory from scratch. This should happen if # the file is entirely missing or if it's somehow corrupted. def rebuild Puppet.notice "Rebuilding inventory file" Puppet.settings.write(:cert_inventory) do |f| f.print "# Inventory of signed certificates\n# SERIAL NOT_BEFORE NOT_AFTER SUBJECT\n" end Puppet::SSL::Certificate.indirection.search("*").each { |cert| add(cert) } end # Find the serial number for a given certificate. def serial(name) return nil unless FileTest.exist?(@path) File.readlines(@path).each do |line| next unless line =~ /^(\S+).+\/CN=#{name}$/ return Integer($1) end + + return nil end end diff --git a/spec/unit/face/ca_spec.rb b/spec/unit/face/ca_spec.rb new file mode 100755 index 000000000..84769605f --- /dev/null +++ b/spec/unit/face/ca_spec.rb @@ -0,0 +1,355 @@ +#!/usr/bin/env rspec +require 'spec_helper' +require 'puppet/face' + +describe Puppet::Face[:ca, '0.1.0'] do + include PuppetSpec::Files + + before :each do + Puppet.run_mode.stubs(:master?).returns(true) + Puppet[:ca] = true + Puppet[:ssldir] = tmpdir("face-ca-ssldir") + + Puppet::SSL::Host.ca_location = :only + Puppet[:certificate_revocation] = true + + # This is way more intimate than I want to be with the implementation, but + # there doesn't seem any other way to test this. --daniel 2011-07-18 + Puppet::SSL::CertificateAuthority.stubs(:instance).returns( + # ...and this actually does the directory creation, etc. + Puppet::SSL::CertificateAuthority.new + ) + end + + def make_certs(csr_names, crt_names) + Array(csr_names).map do |name| + Puppet::SSL::Host.new(name).generate_certificate_request + end + + Array(crt_names).map do |name| + Puppet::SSL::Host.new(name).generate + end + end + + context "#verify" do + let :action do Puppet::Face[:ca, '0.1.0'].get_action(:verify) end + + it "should not explode if there is no certificate" do + expect { + subject.verify('random-host').should == { + :host => 'random-host', :valid => false, + :error => 'Could not find a certificate for random-host' + } + }.should_not raise_error + end + + it "should not explode if there is only a CSR" do + make_certs('random-host', []) + expect { + subject.verify('random-host').should == { + :host => 'random-host', :valid => false, + :error => 'Could not find a certificate for random-host' + } + }.should_not raise_error + end + + it "should verify a signed certificate" do + make_certs([], 'random-host') + subject.verify('random-host').should == { + :host => 'random-host', :valid => true + } + end + + it "should not verify a revoked certificate" do + make_certs([], 'random-host') + subject.revoke('random-host') + + expect { + subject.verify('random-host').should == { + :host => 'random-host', :valid => false, + :error => 'certificate revoked' + } + }.should_not raise_error + end + + it "should verify a revoked certificate if CRL use was turned off" do + make_certs([], 'random-host') + subject.revoke('random-host') + + Puppet[:certificate_revocation] = false + subject.verify('random-host').should == { + :host => 'random-host', :valid => true + } + end + end + + context "#fingerprint" do + let :action do Puppet::Face[:ca, '0.1.0'].get_action(:fingerprint) end + + it "should have a 'digest' option" do + action.should be_option :digest + end + + it "should not explode if there is no certificate" do + expect { + subject.fingerprint('random-host').should be_nil + }.should_not raise_error + end + + it "should fingerprint a CSR" do + make_certs('random-host', []) + expect { + subject.fingerprint('random-host').should =~ /^[0-9A-F:]+$/ + }.should_not raise_error + end + + it "should fingerprint a certificate" do + make_certs([], 'random-host') + subject.fingerprint('random-host').should =~ /^[0-9A-F:]+$/ + end + + %w{md5 MD5 sha1 ShA1 SHA1 RIPEMD160 sha256 sha512}.each do |digest| + it "should fingerprint with #{digest.inspect}" do + make_certs([], 'random-host') + subject.fingerprint('random-host', :digest => digest).should =~ /^[0-9A-F:]+$/ + end + + it "should fingerprint with #{digest.to_sym} as a symbol" do + make_certs([], 'random-host') + subject.fingerprint('random-host', :digest => digest.to_sym). + should =~ /^[0-9A-F:]+$/ + end + end + end + + context "#print" do + let :action do Puppet::Face[:ca, '0.1.0'].get_action(:print) end + + it "should not explode if there is no certificate" do + expect { + subject.print('random-host').should be_nil + }.should_not raise_error + end + + it "should return nothing if there is only a CSR" do + make_certs('random-host', []) + expect { + subject.print('random-host').should be_nil + }.should_not raise_error + end + + it "should return the certificate content if there is a cert" do + make_certs([], 'random-host') + text = subject.print('random-host') + text.should be_an_instance_of String + text.should =~ /^Certificate:/ + text.should =~ /Issuer: CN=Puppet CA: / + text.should =~ /Subject: CN=random-host$/ + end + end + + context "#sign" do + let :action do Puppet::Face[:ca, '0.1.0'].get_action(:sign) end + + it "should not explode if there is no CSR" do + expect { + subject.sign('random-host'). + should == 'Could not find certificate request for random-host' + }.should_not raise_error + end + + it "should not explode if there is a signed cert" do + make_certs([], 'random-host') + expect { + subject.sign('random-host'). + should == 'Could not find certificate request for random-host' + }.should_not raise_error + end + + it "should sign a CSR if one exists" do + make_certs('random-host', []) + subject.sign('random-host').should be_an_instance_of Puppet::SSL::Certificate + + list = subject.list(:signed => true) + list.count.should == 1 + list.first.name.should == 'random-host' + end + end + + context "#generate" do + let :action do Puppet::Face[:ca, '0.1.0'].get_action(:generate) end + + it "should generate a certificate if requested" do + subject.list(:all => true).should == [] + + subject.generate('random-host') + + list = subject.list(:signed => true) + list.count.should == 1 + list.first.name.should == 'random-host' + end + + it "should not explode if a CSR with that name already exists" do + make_certs('random-host', []) + expect { + subject.generate('random-host').should =~ /already has a certificate request/ + }.should_not raise_error + end + + it "should not explode if the certificate with that name already exists" do + make_certs([], 'random-host') + expect { + subject.generate('random-host').should =~ /already has a certificate/ + }.should_not raise_error + end + end + + context "#revoke" do + let :action do Puppet::Face[:ca, '0.1.0'].get_action(:revoke) end + + it "should not explode when asked to revoke something that doesn't exist" do + expect { subject.revoke('nonesuch') }.should_not raise_error + end + + it "should let the user know what went wrong" do + subject.revoke('nonesuch').should == 'Nothing was revoked' + end + + it "should revoke a certificate" do + make_certs([], 'random-host') + found = subject.list(:all => true, :subject => 'random-host') + subject.get_action(:list).when_rendering(:console).call(found). + should =~ /^\+ random-host/ + + subject.revoke('random-host') + + found = subject.list(:all => true, :subject => 'random-host') + subject.get_action(:list).when_rendering(:console).call(found). + should =~ /^- random-host \([:0-9A-F]+\) \(certificate revoked\)/ + end + end + + context "#destroy" do + let :action do Puppet::Face[:ca, '0.1.0'].get_action(:destroy) end + + it "should not explode when asked to delete something that doesn't exist" do + expect { subject.destroy('nonesuch') }.should_not raise_error + end + + it "should let the user know if nothing was deleted" do + subject.destroy('nonesuch').should == "Nothing was deleted" + end + + it "should destroy a CSR, if we have one" do + make_certs('random-host', []) + subject.list(:pending => true, :subject => 'random-host').should_not == [] + + subject.destroy('random-host') + + subject.list(:pending => true, :subject => 'random-host').should == [] + end + + it "should destroy a certificate, if we have one" do + make_certs([], 'random-host') + subject.list(:signed => true, :subject => 'random-host').should_not == [] + + subject.destroy('random-host') + + subject.list(:signed => true, :subject => 'random-host').should == [] + end + + it "should tell the user something was deleted" do + make_certs([], 'random-host') + subject.list(:signed => true, :subject => 'random-host').should_not == [] + subject.destroy('random-host'). + should == "Deleted for random-host: Puppet::SSL::Certificate, Puppet::SSL::Key" + end + end + + context "#list" do + let :action do Puppet::Face[:ca, '0.1.0'].get_action(:list) end + + context "options" do + subject { Puppet::Face[:ca, '0.1.0'].get_action(:list) } + it { should be_option :pending } + it { should be_option :signed } + it { should be_option :all } + it { should be_option :subject } + end + + context "with no hosts in CA" do + [:pending, :signed, :all].each do |type| + it "should return nothing for #{type}" do + subject.list(type => true).should == [] + end + + it "should not fail when a matcher is passed" do + expect { + subject.list(type => true, :subject => '.').should == [] + }.should_not raise_error + end + end + end + + context "with some hosts" do + csr_names = (1..3).map {|n| "csr-#{n}" } + crt_names = (1..3).map {|n| "crt-#{n}" } + all_names = csr_names + crt_names + + { + {} => csr_names, + { :pending => true } => csr_names, + + { :signed => true } => crt_names, + + { :all => true } => all_names, + { :pending => true, :signed => true } => all_names, + }.each do |input, expect| + it "should map #{input.inspect} to #{expect.inspect}" do + make_certs(csr_names, crt_names) + subject.list(input).map(&:name).should =~ expect + end + + ['', '.', '2', 'none'].each do |pattern| + filtered = expect.select {|x| Regexp.new(pattern).match(x) } + + it "should filter all hosts matching #{pattern.inspect} to #{filtered.inspect}" do + make_certs(csr_names, crt_names) + subject.list(input.merge :subject => pattern).map(&:name).should =~ filtered + end + end + end + + context "when_rendering :console" do + { [["csr1.local"], []] => '^ csr1.local ', + [[], ["crt1.local"]] => '^\+ crt1.local ', + [["csr2"], ["crt2"]] => ['^ csr2 ', '^\+ crt2 '] + }.each do |input, pattern| + it "should render #{input.inspect} to match #{pattern.inspect}" do + make_certs(*input) + text = action.when_rendering(:console).call(subject.list(:all => true)) + Array(pattern).each do |item| + text.should =~ Regexp.new(item) + end + end + end + end + end + end + + actions = %w{destroy list revoke generate sign print verify fingerprint} + actions.each do |action| + it { should be_action action } + it "should fail #{action} when not a CA" do + Puppet[:ca] = false + expect { + case subject.method(action).arity + when -1 then subject.send(action) + when -2 then subject.send(action, 'dummy') + else + raise "#{action} has arity #{subject.method(action).arity}" + end + }.should raise_error(/Not a CA/) + end + end +end