summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordanielsdeleo <dan@opscode.com>2013-10-29 16:02:38 -0700
committerdanielsdeleo <dan@getchef.com>2014-03-19 22:32:30 -0700
commitcc2307a9b7774c1b8a70066c84961ecbfd05d5d5 (patch)
treeaeb4778ef163c00f04d071618d0c7c40941a5a9e
parent09f22372674b88e6e3ee7bb9aff406862ae0f27c (diff)
downloadchef-cc2307a9b7774c1b8a70066c84961ecbfd05d5d5.tar.gz
Add SSL check and certificate fetching commands to knife
Fixes CHEF-4711
-rw-r--r--CHANGELOG.md1
-rw-r--r--DOC_CHANGES.md36
-rw-r--r--RELEASE_NOTES.md54
-rw-r--r--lib/chef/knife.rb1
-rw-r--r--lib/chef/knife/ssl_check.rb213
-rw-r--r--lib/chef/knife/ssl_fetch.rb145
-rw-r--r--spec/unit/knife/ssl_check_spec.rb187
-rw-r--r--spec/unit/knife/ssl_fetch_spec.rb151
8 files changed, 788 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 28fcb97360..f3ba5d85eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -48,6 +48,7 @@
* Don't destructively merge subhashes in hash_only_merge!. (CHEF-4918)
* Display correct host name in knife ssh error message (CHEF-5029)
* Knife::UI#confirm now has a default_choice option. (CHEF-5057)
+* Add knife 'ssl check' and 'ssl fetch' commands for debugging SSL errors (CHEF-4711)
## Last Release: 11.10.0 (02/06/2014)
diff --git a/DOC_CHANGES.md b/DOC_CHANGES.md
index 688d11e1d5..f54ebdffbf 100644
--- a/DOC_CHANGES.md
+++ b/DOC_CHANGES.md
@@ -28,3 +28,39 @@ Option similar to `-o` which sets or changes the run_list of a node permanently.
### OHAI 7 Upgrade
Unless there are major issues, 11.12.0 will include OHAI 7. We already have ohai 7 docs in place. We probably need to add some notes to ohai 6 notes that one should now use the newer version when possible.
+
+### New knife command: `knife ssl check [URI]`
+
+The `knife ssl check` command is used to check or troubleshoot SSL
+configuration. When run without arguments, it tests whether chef/knife
+can verify the Chef server's SSL certificate. Otherwise it connects to
+the server specified by the given URL.
+
+Examples:
+
+* Check knife's configuration against the chef-server: `knife ssl check`
+* Check chef-client's configuration: `knife ssl check -c /etc/chef/client.rb`
+* Check whether an external server's SSL certificate can be verified:
+ `knife ssl check https://www.getchef.com`
+
+### New knife command: `knife ssl fetch [URI]`
+
+The `knife ssl fetch` command is used to copy certificates from an HTTPS
+server to the `trusted_certs_dir` of knife or `chef-client`. If the
+certificates match the hostname of the remote server, this command is
+all that is required for knife or chef-client to verify the remote
+server in the future. WARNING: `knife` has no way to determine whether
+the certificates were tampered with in transit. If that happens,
+knife/chef-client will trust potentially forged/malicious certificates
+until they are deleted from the `trusted_certs_dir`. Users are *VERY STRONGLY*
+encouraged to verify the authenticity of the certificates downloaded
+with `knife fetch` by some trustworthy means.
+
+Examples:
+
+* Fetch the chef server's certificates for use with knife:
+ `knife ssl fetch`
+* Fetch the chef server's certificates for use with chef-client:
+ `knife ssl fetch -c /etc/chef/client.rb`
+* Fetch the certificates from an arbitrary server:
+ `knife ssl fetch https://www.getchef.com`
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index faf68846a3..53166e188a 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -8,6 +8,60 @@ Details about the thing that changed that needs to get included in the Release N
-->
# Chef Client Release Notes:
+#### `knife ssl check` and `knife ssl fetch` Commands
+
+As part of our process to transition to verifying SSL certificates by
+default, we've added knife commands to help you test (and fix, if
+needed) your SSL configuration.
+
+`knife ssl check` makes an SSL connection to your Chef server or any
+other HTTPS server and tells you if the server presents a valid
+certificate. If the certificate is not valid, knife will give further
+information about the cause and some instructions on how to remedy the
+issue. For example, if your Chef server uses an untrusted self-signed
+certificate:
+
+```
+ERROR: The SSL certificate of chefserver.test could not be
+verified
+Certificate issuer data:
+/C=US/ST=WA/L=Seattle/O=YouCorp/OU=Operations/CN=chefserver.test/emailAddress=you@example.com
+
+Configuration Info:
+
+OpenSSL Configuration:
+* Version: OpenSSL 1.0.1e 11 Feb 2013
+* Certificate file: /usr/local/etc/openssl/cert.pem
+* Certificate directory: /usr/local/etc/openssl/certs
+Chef SSL Configuration:
+* ssl_ca_path: nil
+* ssl_ca_file: nil
+* trusted_certs_dir: "/Users/ddeleo/.chef/trusted_certs"
+
+TO FIX THIS ERROR:
+
+If the server you are connecting to uses a self-signed certificate, you
+must
+configure chef to trust that server's certificate.
+
+By default, the certificate is stored in the following location on the
+host
+where your chef-server runs:
+
+ /var/opt/chef-server/nginx/ca/SERVER_HOSTNAME.crt
+
+Copy that file to you trusted_certs_dir (currently: /home/user/.chef/trusted_certs)
+using SSH/SCP or some other secure method, then re-run this command to confirm
+that the server's certificate is now trusted.
+```
+
+`knife ssl fetch` allows you to automatically fetch a server's
+certificates to your trusted certs directory. This provides an easy way
+to configure chef to trust your self-signed certificates. Note that
+knife cannot verify that the certificates haven't been tampered with, so
+you should verify their content after downloading.
+
+
#### Chef Solo Missing Dependency Warning ([CHEF-4367](https://tickets.opscode.com/browse/CHEF-4367))
Chef 11.0 introduced ordered evaluation of non-recipe files in
diff --git a/lib/chef/knife.rb b/lib/chef/knife.rb
index eb2c321cab..5cbc968980 100644
--- a/lib/chef/knife.rb
+++ b/lib/chef/knife.rb
@@ -421,6 +421,7 @@ class Chef
# Don't try to load a knife.rb if it wasn't specified.
if config[:config_file]
+ Chef::Config.config_file = config[:config_file]
fetcher = Chef::ConfigFetcher.new(config[:config_file], Chef::Config.config_file_jail)
if fetcher.config_missing?
ui.error("Specified config file #{config[:config_file]} does not exist#{Chef::Config.config_file_jail ? " or is not under config file jail #{Chef::Config.config_file_jail}" : ""}!")
diff --git a/lib/chef/knife/ssl_check.rb b/lib/chef/knife/ssl_check.rb
new file mode 100644
index 0000000000..e98469d5aa
--- /dev/null
+++ b/lib/chef/knife/ssl_check.rb
@@ -0,0 +1,213 @@
+#
+# Author:: Daniel DeLeo (<dan@getchef.com>)
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'chef/knife'
+require 'chef/config'
+
+class Chef
+ class Knife
+ class SslCheck < Chef::Knife
+
+ deps do
+ require 'pp'
+ require 'socket'
+ require 'uri'
+ require 'chef/http/ssl_policies'
+ require 'openssl'
+ end
+
+ banner "knife ssl check [URL] (options)"
+
+ def initialize(*args)
+ @host = nil
+ @verify_peer_socket = nil
+ @ssl_policy = HTTP::DefaultSSLPolicy
+ super
+ end
+
+ def uri
+ @uri ||= begin
+ Chef::Log.debug("Checking SSL cert on #{given_uri}")
+ URI.parse(given_uri)
+ end
+ end
+
+ def given_uri
+ (name_args[0] or Chef::Config.chef_server_url)
+ end
+
+ def host
+ uri.host
+ end
+
+ def port
+ uri.port
+ end
+
+ def validate_uri
+ unless host && port
+ invalid_uri!
+ end
+ rescue URI::Error
+ invalid_uri!
+ end
+
+ def invalid_uri!
+ ui.error("Given URI: `#{given_uri}' is invalid")
+ show_usage
+ exit 1
+ end
+
+
+ def verify_peer_socket
+ @verify_peer_socket ||= begin
+ tcp_connection = TCPSocket.new(host, port)
+ OpenSSL::SSL::SSLSocket.new(tcp_connection, verify_peer_ssl_context)
+ end
+ end
+
+ def verify_peer_ssl_context
+ @verify_peer_ssl_context ||= begin
+ verify_peer_context = OpenSSL::SSL::SSLContext.new
+ @ssl_policy.apply_to(verify_peer_context)
+ verify_peer_context.verify_mode = OpenSSL::SSL::VERIFY_PEER
+ verify_peer_context
+ end
+ end
+
+ def noverify_socket
+ @noverify_socket ||= begin
+ tcp_connection = TCPSocket.new(host, port)
+ OpenSSL::SSL::SSLSocket.new(tcp_connection, noverify_peer_ssl_context)
+ end
+ end
+
+ def noverify_peer_ssl_context
+ @noverify_peer_ssl_context ||= begin
+ noverify_peer_context = OpenSSL::SSL::SSLContext.new
+ @ssl_policy.apply_to(noverify_peer_context)
+ noverify_peer_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
+ noverify_peer_context
+ end
+ end
+
+ def verify_cert
+ ui.msg("Connecting to host #{host}:#{port}")
+ verify_peer_socket.connect
+ true
+ rescue OpenSSL::SSL::SSLError => e
+ ui.error "The SSL certificate of #{host} could not be verified"
+ Chef::Log.debug e.message
+ debug_invalid_cert
+ false
+ end
+
+ def verify_cert_host
+ verify_peer_socket.post_connection_check(host)
+ true
+ rescue OpenSSL::SSL::SSLError => e
+ ui.error "The SSL cert is signed by a trusted authority but is not valid for the given hostname"
+ Chef::Log.debug(e)
+ debug_invalid_host
+ false
+ end
+
+ def debug_invalid_cert
+ noverify_socket.connect
+ issuer_info = noverify_socket.peer_cert.issuer
+ ui.msg("Certificate issuer data: #{issuer_info}")
+
+ ui.msg("\n#{ui.color("Configuration Info:", :bold)}\n\n")
+ debug_ssl_settings
+ debug_chef_ssl_config
+
+ ui.err(<<-ADVICE)
+
+#{ui.color("TO FIX THIS ERROR:", :bold)}
+
+If the server you are connecting to uses a self-signed certificate, you must
+configure chef to trust that server's certificate.
+
+By default, the certificate is stored in the following location on the host
+where your chef-server runs:
+
+ /var/opt/chef-server/nginx/ca/SERVER_HOSTNAME.crt
+
+Copy that file to you trusted_certs_dir (currently: #{configuration.trusted_certs_dir})
+using SSH/SCP or some other secure method, then re-run this command to confirm
+that the server's certificate is now trusted.
+
+ADVICE
+ end
+
+ def debug_invalid_host
+ noverify_socket.connect
+ subject = noverify_socket.peer_cert.subject
+ cn_field_tuple = subject.to_a.find {|field| field[0] == "CN" }
+ cn = cn_field_tuple[1]
+
+ ui.error("You are attempting to connect to: '#{host}'")
+ ui.error("The server's certificate belongs to '#{cn}'")
+ ui.err(<<-ADVICE)
+
+#{ui.color("TO FIX THIS ERROR:", :bold)}
+
+The solution for this issue depends on your networking configuration. If you
+are able to connect to this server using the hostname #{cn}
+instead of #{host}, then you can resolve this issue by updating chef_server_url
+in your configuration file.
+
+If you are not able to connect to the server using the hostname #{cn}
+you will have to update the certificate on the server to use the correct hostname.
+ADVICE
+ end
+
+ def debug_ssl_settings
+ ui.err "OpenSSL Configuration:"
+ ui.err "* Version: #{OpenSSL::OPENSSL_VERSION}"
+ ui.err "* Certificate file: #{OpenSSL::X509::DEFAULT_CERT_FILE}"
+ ui.err "* Certificate directory: #{OpenSSL::X509::DEFAULT_CERT_DIR}"
+ end
+
+ def debug_chef_ssl_config
+ ui.err "Chef SSL Configuration:"
+ ui.err "* ssl_ca_path: #{configuration.ssl_ca_path.inspect}"
+ ui.err "* ssl_ca_file: #{configuration.ssl_ca_file.inspect}"
+ ui.err "* trusted_certs_dir: #{configuration.trusted_certs_dir.inspect}"
+ end
+
+ def configuration
+ Chef::Config
+ end
+
+ def run
+ validate_uri
+ if verify_cert && verify_cert_host
+ ui.msg "Successfully verified certificates from `#{host}'"
+ else
+ exit 1
+ end
+ end
+
+ end
+ end
+end
+
+
+
+
diff --git a/lib/chef/knife/ssl_fetch.rb b/lib/chef/knife/ssl_fetch.rb
new file mode 100644
index 0000000000..5626a5610d
--- /dev/null
+++ b/lib/chef/knife/ssl_fetch.rb
@@ -0,0 +1,145 @@
+#
+# Author:: Daniel DeLeo (<dan@getchef.com>)
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'chef/knife/ssl_fetch'
+require 'chef/config'
+
+class Chef
+ class Knife
+ class SslFetch < Chef::Knife
+
+ deps do
+ require 'pp'
+ require 'socket'
+ require 'uri'
+ require 'openssl'
+ end
+
+ banner "knife ssl fetch [URL] (options)"
+
+ def initialize(*args)
+ super
+ @uri = nil
+ end
+
+ def uri
+ @uri ||= begin
+ Chef::Log.debug("Checking SSL cert on #{given_uri}")
+ URI.parse(given_uri)
+ end
+ end
+
+ def given_uri
+ (name_args[0] or Chef::Config.chef_server_url)
+ end
+
+ def host
+ uri.host
+ end
+
+ def port
+ uri.port
+ end
+
+ def validate_uri
+ unless host && port
+ invalid_uri!
+ end
+ rescue URI::Error
+ invalid_uri!
+ end
+
+ def invalid_uri!
+ ui.error("Given URI: `#{given_uri}' is invalid")
+ show_usage
+ exit 1
+ end
+
+ def remote_cert_chain
+ tcp_connection = TCPSocket.new(host, port)
+ shady_ssl_connection = OpenSSL::SSL::SSLSocket.new(tcp_connection, noverify_peer_ssl_context)
+ shady_ssl_connection.connect
+ shady_ssl_connection.peer_cert_chain
+ end
+
+ def noverify_peer_ssl_context
+ @noverify_peer_ssl_context ||= begin
+ noverify_peer_context = OpenSSL::SSL::SSLContext.new
+ noverify_peer_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
+ noverify_peer_context
+ end
+ end
+
+
+ def cn_of(certificate)
+ subject = certificate.subject
+ cn_field_tuple = subject.to_a.find {|field| field[0] == "CN" }
+ cn_field_tuple[1]
+ end
+
+ # Convert the CN of a certificate into something that will work well as a
+ # filename. To do so, all `*` characters are converted to the string
+ # "wildcard" and then all characters other than alphanumeric and hypen
+ # characters are converted to underscores.
+ # NOTE: There is some confustion about what the CN will contain when
+ # using internationalized domain names. RFC 6125 mandates that the ascii
+ # representation be used, but it is not clear whether this is followed in
+ # practice.
+ # https://tools.ietf.org/html/rfc6125#section-6.4.2
+ def normalize_cn(cn)
+ cn.gsub("*", "wildcard").gsub(/[^[:alnum:]\-]/, '_')
+ end
+
+ def configuration
+ Chef::Config
+ end
+
+ def trusted_certs_dir
+ configuration.trusted_certs_dir
+ end
+
+ def write_cert(cert)
+ FileUtils.mkdir_p(trusted_certs_dir)
+ cn = cn_of(cert)
+ filename = File.join(trusted_certs_dir, "#{normalize_cn(cn)}.crt")
+ ui.msg("Adding certificate for #{cn} in #{filename}")
+ File.open(filename, File::CREAT|File::TRUNC|File::RDWR, 0644) do |f|
+ f.print(cert.to_s)
+ end
+ end
+
+ def run
+ validate_uri
+ ui.warn(<<-TRUST_TRUST)
+Certificates from #{host} will be fetched and placed in your trusted_cert
+directory (#{trusted_certs_dir}).
+
+Knife has no means to verify these are the correct certificates. You should
+verify the authenticity of these certificates after downloading.
+
+TRUST_TRUST
+ remote_cert_chain.each do |cert|
+ write_cert(cert)
+ end
+ end
+
+
+ end
+ end
+end
+
diff --git a/spec/unit/knife/ssl_check_spec.rb b/spec/unit/knife/ssl_check_spec.rb
new file mode 100644
index 0000000000..32405a5977
--- /dev/null
+++ b/spec/unit/knife/ssl_check_spec.rb
@@ -0,0 +1,187 @@
+#
+# Author:: Daniel DeLeo (<dan@getchef.com>)
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require "spec_helper"
+require 'stringio'
+
+describe Chef::Knife::SslCheck do
+
+ let(:name_args) { [] }
+ let(:stdout_io) { StringIO.new }
+ let(:stderr_io) { StringIO.new }
+
+ def stderr
+ stderr_io.string
+ end
+
+ def stdout
+ stdout_io.string
+ end
+
+ subject(:ssl_check) do
+ s = Chef::Knife::SslCheck.new
+ s.ui.stub(:stdout).and_return(stdout_io)
+ s.ui.stub(:stderr).and_return(stderr_io)
+ s.name_args = name_args
+ s
+ end
+
+ before do
+ Chef::Config.chef_server_url = "https://example.com:8443/chef-server"
+ end
+
+ context "when no arguments are given" do
+ it "uses the chef_server_url as the host to check" do
+ expect(ssl_check.host).to eq("example.com")
+ expect(ssl_check.port).to eq(8443)
+ end
+ end
+
+ context "when a specific URI is given" do
+ let(:name_args) { %w{https://example.test:10443/foo} }
+
+ it "checks the SSL configuration against the given host" do
+ expect(ssl_check.host).to eq("example.test")
+ expect(ssl_check.port).to eq(10443)
+ end
+ end
+
+ context "when an invalid URI is given" do
+
+ let(:name_args) { %w{foo.test} }
+
+ it "prints an error and exits" do
+ expect { ssl_check.run }.to raise_error(SystemExit)
+ expected_stdout=<<-E
+USAGE: knife ssl check [URL] (options)
+E
+ expected_stderr=<<-E
+ERROR: Given URI: `foo.test' is invalid
+E
+ expect(stdout_io.string).to eq(expected_stdout)
+ expect(stderr_io.string).to eq(expected_stderr)
+ end
+
+ context "and its malformed enough to make URI.parse barf" do
+
+ let(:name_args) { %w{ftp://lkj\\blah:example.com/blah} }
+
+ it "prints an error and exits" do
+ expect { ssl_check.run }.to raise_error(SystemExit)
+ expected_stdout=<<-E
+USAGE: knife ssl check [URL] (options)
+E
+ expected_stderr=<<-E
+ERROR: Given URI: `#{name_args[0]}' is invalid
+E
+ expect(stdout_io.string).to eq(expected_stdout)
+ expect(stderr_io.string).to eq(expected_stderr)
+ end
+ end
+ end
+
+ describe "verifying the remote certificate" do
+ let(:name_args) { %w{https://foo.example.com:8443} }
+
+ let(:tcp_socket) { double(TCPSocket) }
+ let(:ssl_socket) { double(OpenSSL::SSL::SSLSocket) }
+
+ before do
+ TCPSocket.should_receive(:new).with("foo.example.com", 8443).and_return(tcp_socket)
+ OpenSSL::SSL::SSLSocket.should_receive(:new).with(tcp_socket, ssl_check.verify_peer_ssl_context).and_return(ssl_socket)
+ end
+
+ def run
+ ssl_check.run
+ rescue Exception
+ #puts "OUT: #{stdout_io.string}"
+ #puts "ERR: #{stderr_io.string}"
+ raise
+ end
+
+ context "when the remote host's certificate is valid" do
+
+ before do
+ ssl_socket.should_receive(:connect) # no error
+ ssl_socket.should_receive(:post_connection_check).with("foo.example.com") # no error
+ end
+
+ it "prints a success message" do
+ ssl_check.run
+ expect(stdout_io.string).to include("Successfully verified certificates from `foo.example.com'")
+ end
+ end
+
+ describe "and the certificate is not valid" do
+
+ let(:tcp_socket_for_debug) { double(TCPSocket) }
+ let(:ssl_socket_for_debug) { double(OpenSSL::SSL::SSLSocket) }
+
+ let(:self_signed_crt_path) { File.join(CHEF_SPEC_DATA, "trusted_certs", "example.crt") }
+ let(:self_signed_crt) { OpenSSL::X509::Certificate.new(File.read(self_signed_crt_path)) }
+
+ before do
+ trap(:INT, "DEFAULT")
+
+ TCPSocket.should_receive(:new).
+ with("foo.example.com", 8443).
+ and_return(tcp_socket_for_debug)
+ OpenSSL::SSL::SSLSocket.should_receive(:new).
+ with(tcp_socket_for_debug, ssl_check.noverify_peer_ssl_context).
+ and_return(ssl_socket_for_debug)
+ end
+
+ context "when the certificate's CN does not match the hostname" do
+ before do
+ ssl_socket.should_receive(:connect) # no error
+ ssl_socket.should_receive(:post_connection_check).
+ with("foo.example.com").
+ and_raise(OpenSSL::SSL::SSLError)
+ ssl_socket_for_debug.should_receive(:connect)
+ ssl_socket_for_debug.should_receive(:peer_cert).and_return(self_signed_crt)
+ end
+
+ it "shows the CN used by the certificate and prints an error" do
+ expect { run }.to raise_error(SystemExit)
+ expect(stderr).to include("The SSL cert is signed by a trusted authority but is not valid for the given hostname")
+ expect(stderr).to include("You are attempting to connect to: 'foo.example.com'")
+ expect(stderr).to include("The server's certificate belongs to 'example.local'")
+ end
+
+ end
+
+ context "when the cert is not signed by any trusted authority" do
+ before do
+ ssl_socket.should_receive(:connect).
+ and_raise(OpenSSL::SSL::SSLError)
+ ssl_socket_for_debug.should_receive(:connect)
+ ssl_socket_for_debug.should_receive(:peer_cert).and_return(self_signed_crt)
+ end
+
+ it "shows the CN used by the certificate and prints an error" do
+ expect { run }.to raise_error(SystemExit)
+ expect(stderr).to include("The SSL certificate of foo.example.com could not be verified")
+ end
+
+ end
+ end
+
+ end
+
+end
+
diff --git a/spec/unit/knife/ssl_fetch_spec.rb b/spec/unit/knife/ssl_fetch_spec.rb
new file mode 100644
index 0000000000..0d3c8913f7
--- /dev/null
+++ b/spec/unit/knife/ssl_fetch_spec.rb
@@ -0,0 +1,151 @@
+#
+# Author:: Daniel DeLeo (<dan@getchef.com>)
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'spec_helper'
+require 'chef/knife/ssl_fetch'
+
+describe Chef::Knife::SslFetch do
+
+ let(:name_args) { [] }
+ let(:stdout_io) { StringIO.new }
+ let(:stderr_io) { StringIO.new }
+
+ def stderr
+ stderr_io.string
+ end
+
+ def stdout
+ stdout_io.string
+ end
+
+ subject(:ssl_fetch) do
+ s = Chef::Knife::SslFetch.new
+ s.name_args = name_args
+ s.ui.stub(:stdout).and_return(stdout_io)
+ s.ui.stub(:stderr).and_return(stderr_io)
+ s
+ end
+
+ context "when no arguments are given" do
+
+ before do
+ Chef::Config.chef_server_url = "https://example.com:8443/chef-server"
+ end
+
+ it "uses the chef_server_url as the host to fetch" do
+ expect(ssl_fetch.host).to eq("example.com")
+ expect(ssl_fetch.port).to eq(8443)
+ end
+ end
+
+ context "when a specific URI is given" do
+ let(:name_args) { %w{https://example.test:10443/foo} }
+
+ it "fetchs the SSL configuration against the given host" do
+ expect(ssl_fetch.host).to eq("example.test")
+ expect(ssl_fetch.port).to eq(10443)
+ end
+ end
+
+ context "when an invalid URI is given" do
+
+ let(:name_args) { %w{foo.test} }
+
+ it "prints an error and exits" do
+ expect { ssl_fetch.run }.to raise_error(SystemExit)
+ expected_stdout=<<-E
+USAGE: knife ssl fetch [URL] (options)
+E
+ expected_stderr=<<-E
+ERROR: Given URI: `foo.test' is invalid
+E
+ expect(stdout_io.string).to eq(expected_stdout)
+ expect(stderr_io.string).to eq(expected_stderr)
+ end
+
+ context "and its malformed enough to make URI.parse barf" do
+
+ let(:name_args) { %w{ftp://lkj\\blah:example.com/blah} }
+
+ it "prints an error and exits" do
+ expect { ssl_fetch.run }.to raise_error(SystemExit)
+ expected_stdout=<<-E
+USAGE: knife ssl fetch [URL] (options)
+E
+ expected_stderr=<<-E
+ERROR: Given URI: `#{name_args[0]}' is invalid
+E
+ expect(stdout_io.string).to eq(expected_stdout)
+ expect(stderr_io.string).to eq(expected_stderr)
+ end
+ end
+ end
+
+ describe "normalizing CNs for use as paths" do
+
+ it "normalizes '*' to 'wildcard'" do
+ expect(ssl_fetch.normalize_cn("*.example.com")).to eq("wildcard_example_com")
+ end
+
+ it "normalizes non-alnum and hyphen characters to underscores" do
+ expect(ssl_fetch.normalize_cn("Billy-Bob's Super Awesome CA!")).to eq("Billy-Bob_s_Super_Awesome_CA_")
+ end
+
+ end
+
+ describe "fetching the remote cert chain" do
+
+ let(:name_args) { %w{https://foo.example.com:8443} }
+
+ let(:tcp_socket) { double(TCPSocket) }
+ let(:ssl_socket) { double(OpenSSL::SSL::SSLSocket) }
+
+ let(:self_signed_crt_path) { File.join(CHEF_SPEC_DATA, "trusted_certs", "example.crt") }
+ let(:self_signed_crt) { OpenSSL::X509::Certificate.new(File.read(self_signed_crt_path)) }
+
+ let(:trusted_certs_dir) { Dir.mktmpdir }
+
+ def run
+ ssl_fetch.run
+ rescue Exception
+ puts "OUT: #{stdout_io.string}"
+ puts "ERR: #{stderr_io.string}"
+ raise
+ end
+
+ before do
+ Chef::Config.trusted_certs_dir = trusted_certs_dir
+
+ TCPSocket.should_receive(:new).with("foo.example.com", 8443).and_return(tcp_socket)
+ OpenSSL::SSL::SSLSocket.should_receive(:new).with(tcp_socket, ssl_fetch.noverify_peer_ssl_context).and_return(ssl_socket)
+ ssl_socket.should_receive(:connect)
+ ssl_socket.should_receive(:peer_cert_chain).and_return([self_signed_crt])
+ end
+
+ after do
+ FileUtils.rm_rf(trusted_certs_dir)
+ end
+
+ it "fetches the cert chain and writes the certs to the trusted_certs_dir" do
+ run
+ stored_cert_path = File.join(trusted_certs_dir, "example_local.crt")
+ expect(File).to exist(stored_cert_path)
+ expect(File.read(stored_cert_path)).to eq(File.read(self_signed_crt_path))
+ end
+ end
+end