diff --git a/acceptance/tests/environment/directory_environment_production_created_master.rb b/acceptance/tests/environment/directory_environment_production_created_master.rb index ed322c143..0ad234ca4 100644 --- a/acceptance/tests/environment/directory_environment_production_created_master.rb +++ b/acceptance/tests/environment/directory_environment_production_created_master.rb @@ -1,44 +1,44 @@ test_name 'ensure production environment created by master if missing' testdir = create_tmpdir_for_user master, 'prod-env-created' step 'make environmentpath' master_user = on(master, puppet("master --configprint user")).stdout.strip cert_path = on(master, puppet('config', 'print', 'hostcert')).stdout.strip key_path = on(master, puppet('config', 'print', 'hostprivkey')).stdout.strip cacert_path = on(master, puppet('config', 'print', 'localcacert')).stdout.strip apply_manifest_on(master, <<-MANIFEST, :catch_failures => true) File { ensure => directory, owner => #{master_user}, group => #{master['group']}, mode => '0640', } file { "#{testdir}":; "#{testdir}/environments":; } MANIFEST master_opts = { 'main' => { 'environmentpath' => "#{testdir}/environments", } } step 'run master; ensure production environment created' with_puppet_running_on(master, master_opts, testdir) do if master.is_using_passenger? - on(master, "curl -k --cert #{cert_path} --key #{key_path} --cacert #{cacert_path} https://localhost:8140/v2.0/environments") + on(master, "curl -k --cert #{cert_path} --key #{key_path} --cacert #{cacert_path} https://localhost:8140/puppet/v2.0/environments") end on(master, "test -d '#{testdir}/environments/production'") step 'ensure catalog returned from production env with no changes' agents.each do |agent| on(agent, puppet("agent -t --server #{master} --environment production --detailed-exitcodes")) do # detailed-exitcodes produces a 0 when no changes are made. assert_equal(0, exit_code) end end end diff --git a/acceptance/tests/external_ca_support/fixtures/auth.conf b/acceptance/tests/external_ca_support/fixtures/auth.conf index 2e1e6498d..499c544f6 100644 --- a/acceptance/tests/external_ca_support/fixtures/auth.conf +++ b/acceptance/tests/external_ca_support/fixtures/auth.conf @@ -1,60 +1,60 @@ -# Puppet 3.1.1 auth.conf, modified to allow requests from example.org for +# Puppet 4.0.0 auth.conf, modified to allow requests from example.org for # external ca testing. # allow nodes to retrieve their own catalog path ~ ^/puppet/v3/catalog/([^/]+)$ method find allow *.example.org allow $1 # allow nodes to retrieve their own node definition path ~ ^/puppet/v3/node/([^/]+)$ method find allow *.example.org allow $1 # allow all nodes to access the certificates services -path /puppet/v3/certificate_revocation_list/ca +path /puppet-ca/v1/certificate_revocation_list/ca method find allow * # allow all nodes to store their own reports path ~ ^/puppet/v3/report/([^/]+)$ method save allow *.example.org allow $1 # Allow all nodes to access all file services; this is necessary for # pluginsync, file serving from modules, and file serving from custom # mount points (see fileserver.conf). Note that the `/file` prefix matches # requests to both the file_metadata and file_content paths. See "Examples" # above if you need more granular access control for custom mount points. path /puppet/v3/file allow * ### Unauthenticated ACLs, for clients without valid certificates; authenticated ### clients can also access these paths, though they rarely need to. # allow access to the CA certificate; unauthenticated nodes need this # in order to validate the puppet master's certificate -path /puppet/v3/certificate/ca +path /puppet-ca/v1/certificate/ca auth any method find allow * # allow nodes to retrieve the certificate they requested earlier -path /puppet/v3/certificate/ +path /puppet-ca/v1/certificate/ auth any method find allow * # allow nodes to request a new certificate -path /puppet/v3/certificate_request +path /puppet-ca/v1/certificate_request auth any method find, save allow * # deny everything else; this ACL is not strictly necessary, but # illustrates the default policy. path / auth any diff --git a/acceptance/tests/external_ca_support/fixtures/certchain.sh b/acceptance/tests/external_ca_support/fixtures/certchain.sh index ac73e7a06..8735dfb63 100755 --- a/acceptance/tests/external_ca_support/fixtures/certchain.sh +++ b/acceptance/tests/external_ca_support/fixtures/certchain.sh @@ -1,554 +1,554 @@ #! /bin/bash ## NOTE: ## This script requires the following in /etc/hosts: ## 127.0.0.2 puppet master1.example.org # This will fail with a stock puppet 3.1.1, but will succeed if all of the # certificate subjects contain only the "CN" portion, and no O, OU, or # emailAddress. # basic config to describe the environment # B="/tmp/certchain" B="$(mktemp -d -t certchain.XXXXXXXX)" HTTPS_PORT=8443 OPENSSL=$(which openssl) # utility method to dedent a heredoc dedent() { python -c 'import sys, textwrap; print textwrap.dedent(sys.stdin.read())' } # invoke openssl openssl() { echo "----" echo "running" ${OPENSSL} ${@} echo " in $PWD" ${OPENSSL} "${@}" } show_cert() { local cert="$1" # openssl x509 -in "${cert}" -noout -text -nameopt RFC2253 openssl x509 -in "${cert}" -noout -text } hash_cert() { local cert="$1" local certdir="${B}/certdir" local h=$(${OPENSSL} x509 -hash -noout -in ${cert}) mkdir -p "${certdir}" ln -s "$cert" "${certdir}/${h}.0" } show_crl() { local crl="$1" openssl crl -in "${crl}" -noout -text } hash_crl() { local crl="$1" local certdir="${B}/certdir" local h=$(${OPENSSL} crl -hash -noout -in ${crl}) mkdir -p "${certdir}" ln -s "$crl" "${certdir}/${h}.r0" } # clean out any messes this script has made clean_up() { stop_apache rm -rf "$B" } stop_apache() { local pid pidfile="${B}/apache/httpd.pid" while true; do pid=$(cat "${pidfile}" 2>/dev/null || true) [ -z "$pid" ] && break # break if the pid is gone kill "$pid" || break # break if the kill fails (process is gone) sleep 0.1 done } # perform basic setup: make directories, etc. set_up() { mkdir -p "$B" } # create CA certificates: # # * $B/root_ca # * $B/master{1..2}_ca # # with each containing: # # * openssl.conf -- suitable for signing certificates # * ca-$name.key -- PEM format certificate key, with no password # * ca-$name.crt -- PEM format certificate create_ca_certs() { local name cn dir subj ca_config for name in root agent-ca master-ca; do dir="${B}/${name}" mkdir -p "${dir}" ( cd "${dir}" # if this is the root cert, make a self-signed cert if [ "$name" = "root" ]; then subj="/CN=Root CA/OU=Server Operations/O=Example Org, LLC" openssl req -new -newkey rsa:2048 -days 7300 -nodes -x509 \ -subj "${subj}" -keyout "ca-${name}.key" -out "ca-${name}.crt" else # make a new key for the CA openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "ca-${name}.key" # build a CSR out of it dedent > openssl.tmp << OPENSSL_TMP [req] prompt = no distinguished_name = dn_config [dn_config] commonName = Intermediate CA (${name}) emailAddress = test@example.org organizationalUnitName = Server Operations organizationName = Example Org, LLC OPENSSL_TMP openssl req -config openssl.tmp -new -key "ca-${name}.key" -out "ca-${name}.csr" rm openssl.tmp # sign it with the root CA openssl ca -config ../root/openssl.conf -in "ca-${name}.csr" -notext -out "ca-${name}.crt" -batch # clean up the now-redundant csr rm "ca-${name}.csr" fi # set up the CA config; this uses the same file for all, but with different options # for the root and master CAs [ "$name" = "root" ] && ca_config=root_ca_config || ca_config=master_ca_config dedent > openssl.conf << OPENSSL_CONF SAN = DNS:puppet [ca] default_ca = ${ca_config} # Root CA [root_ca_config] certificate = ${dir}/ca-${name}.crt private_key = ${dir}/ca-${name}.key database = ${dir}/inventory.txt new_certs_dir = ${dir}/certs serial = ${dir}/serial default_crl_days = 7300 default_days = 7300 default_md = sha1 policy = root_ca_policy x509_extensions = root_ca_exts [root_ca_policy] commonName = supplied emailAddress = supplied organizationName = supplied organizationalUnitName = supplied [root_ca_exts] authorityKeyIdentifier = keyid,issuer:always basicConstraints = critical,CA:true keyUsage = keyCertSign, cRLSign # Master CA [master_ca_config] certificate = ${dir}/ca-${name}.crt private_key = ${dir}/ca-${name}.key database = ${dir}/inventory.txt new_certs_dir = ${dir}/certs serial = ${dir}/serial default_crl_days = 7300 default_days = 7300 default_md = sha1 policy = master_ca_policy x509_extensions = master_ca_exts # Master CA (Email) [master_ca_email_config] certificate = ${dir}/ca-${name}.crt private_key = ${dir}/ca-${name}.key database = ${dir}/inventory.txt new_certs_dir = ${dir}/certs serial = ${dir}/serial default_crl_days = 7300 default_days = 7300 default_md = sha1 email_in_dn = yes policy = master_ca_email_policy x509_extensions = master_ca_exts [master_ca_policy] commonName = supplied [master_ca_email_policy] commonName = supplied emailAddress = supplied # default extensions for clients [master_ca_exts] authorityKeyIdentifier = keyid,issuer:always basicConstraints = critical,CA:false keyUsage = keyEncipherment, digitalSignature extendedKeyUsage = serverAuth, clientAuth [master_ssl_exts] authorityKeyIdentifier = keyid,issuer:always basicConstraints = critical,CA:false keyUsage = keyEncipherment, digitalSignature extendedKeyUsage = serverAuth, clientAuth subjectAltName = \$ENV::SAN # extensions for the master certificate (specifically adding subjectAltName) [master_self_ca_exts] authorityKeyIdentifier = keyid,issuer:always basicConstraints = critical,CA:false keyUsage = keyEncipherment, digitalSignature extendedKeyUsage = serverAuth, clientAuth # include the master's fqdn here, as well as in the CN, to work # around https://bugs.ruby-lang.org/issues/6493 # NOTE: Alt Names should be set in the request, so they know # their FQDN # subjectAltName = DNS:puppet,DNS:${name}.example.org OPENSSL_CONF touch inventory.txt mkdir certs echo 01 > serial show_cert "${dir}/ca-${name}.crt" hash_cert "${dir}/ca-${name}.crt" # generate an empty CRL for this CA openssl ca -config "${dir}/openssl.conf" -gencrl -out "${dir}/ca-${name}.crl" show_crl "${dir}/ca-${name}.crl" hash_crl "${dir}/ca-${name}.crl" ) done } # revoke leaf cert for $1 issued by master CA $2 revoke_leaf_cert() { local fqdn="$1" local ca="${2:-agent-ca}" local dir="${B}/${ca}" # revoke the cert and regenerate the crl openssl ca -config "${dir}/openssl.conf" -revoke "${B}/leaves/${fqdn}.issued_by.${ca}.crt" openssl ca -config "${dir}/openssl.conf" -gencrl -out "${dir}/ca-${ca}.crl" show_crl "${dir}/ca-${ca}.crl" # kill -HUP $(< "${B}/apache/httpd.pid") } # revoke CA cert for $1 revoke_ca_cert() { local master="$1" local dir="${B}/root" # revoke the cert and regenerate the crl openssl ca -config "${dir}/openssl.conf" -revoke "${B}/${master}/ca-${master}.crt" openssl ca -config "${dir}/openssl.conf" -gencrl -out "${dir}/ca-root.crl" show_crl "${dir}/ca-root.crl" kill -HUP $(< "${B}/apache/httpd.pid") } # create a "leaf" certificate for the given fqdn, signed by the given ca name. # $fqdn.issued_by.${ca}.{key,crt} will be placed in "${B}/leaves" create_leaf_cert() { local fqdn="$1" ca="$2" exts="$3" local masterdir="${B}/${ca}" local dir="${B}/leaves" local fname="${fqdn}.issued_by.${ca}" [ -n "$exts" ] && exts="-extensions $exts" mkdir -p "${dir}" ( cd "${dir}" openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${fname}.key" openssl req -subj "/CN=${fqdn}" -new -key "${fname}.key" -out "${fname}.csr" CN="${fqdn}" SAN="DNS:${fqdn}, DNS:${fqdn%%.*}, DNS:puppet, DNS:puppetmaster" \ openssl ca -config "${B}/${ca}/openssl.conf" -in "${fname}.csr" -notext \ -out "${fname}.crt" -batch $exts ) show_cert "${dir}/${fname}.crt" } # Note, we can parameterize SubjectAltNames using environment variables. create_leaf_certs() { create_leaf_cert master1.example.org master-ca master_ssl_exts create_leaf_cert master2.example.org master-ca master_ssl_exts create_leaf_cert agent1.example.org agent-ca create_leaf_cert agent2.example.org agent-ca create_leaf_cert agent3.example.org agent-ca create_leaf_cert master1.example.org agent-ca master_ssl_exts # rogue # create_leaf_cert master1.example.org root master_ssl_exts # rogue create_leaf_cert agent1.example.org master-ca # rogue # create_leaf_cert agent1.example.org root # rogue } # create a "leaf" certificate for the given fqdn, signed by the given ca name, # with an email address in the subject. # $fqdn.issued_by.${ca}.{key,crt} will be placed in "${B}/leaves" create_leaf_email_cert() { local fqdn="$1" ca="$2" exts="$3" local masterdir="${B}/${ca}" local dir="${B}/leaves" local fname="${fqdn}.issued_by.${ca}" mkdir -p "${dir}" ( cd "${dir}" openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${fname}.key" openssl req -subj "/CN=${fqdn}/emailAddress=test@example.com" -new -key "${fname}.key" -out "${fname}.csr" openssl ca -config "${B}/${ca}/openssl.conf" -name master_ca_email_config \ -in "${fname}.csr" -notext -out "${fname}.crt" -batch $exts_arg ) show_cert "${dir}/${fname}.crt" } create_leaf_email_certs() { create_leaf_email_cert master-email1.example.org master-ca master_self_ca_exts create_leaf_email_cert master-email2.example.org master-ca master_self_ca_exts create_leaf_email_cert agent-email1.example.org agent-ca create_leaf_email_cert agent-email2.example.org agent-ca create_leaf_email_cert agent-email3.example.org agent-ca } set_up_apache() { local apachedir="${B}/apache" mkdir -p "${apachedir}/puppetmaster/public" echo 'passed'> "${apachedir}/puppetmaster/public/test.txt" dedent > "${apachedir}/httpd.conf" < Require all granted RackAutoDetect On RackBaseURI / HTTPD_CONF } set_up_puppetmaster() { local apachedir="${B}/apache" local masterdir="${B}/puppetmaster" local confdir="${masterdir}/conf" local environmentdir="${confdir}/environments/production" mkdir -p "${confdir}" "${masterdir}/var" "${environmentdir}/manifests" dedent > "${apachedir}/puppetmaster/config.ru" < "${masterdir}/conf/puppet.conf" < "${environmentdir}/manifests/site.pp" < "yes I was" } } SITE_PP } start_apache() { local apachedir="${B}/apache" if ! httpd -f "${apachedir}/httpd.conf"; then [ -f "${apachedir}/error_log" ] && tail "${apachedir}/error_log" false fi } check_apache() { # verify the SSL config with openssl. Note that s_client exits with 0 # no matter what, so this greps the output for an OK status. Also note # that this only checks that the validation of the server certs is OK, since # client validation is optional in the httpd config. echo $'GET /test.txt HTTP/1.0\n' | \ openssl s_client -connect "127.0.0.1:${HTTPS_PORT}" -verify 2 \ -cert "${B}/leaves/client2a.example.org.crt" \ -key "${B}/leaves/client2a.example.org.key" \ -CAfile "${B}/root/ca-root.crt" \ | tee "${B}/verify.out" cat "${B}/apache/error_log" grep -q "Verify return code: 0 (ok)" "${B}/verify.out" } check_puppetmaster() { # this is insecure, because otherwise curl will check that 127.0.0.1 == # master1.example.org and fail; validation of the server certs is done # above in check_apache, so this is fine. curl -vks --fail \ --header 'Accept: yaml' \ --cert "${B}/leaves/client2a.example.org.crt" \ --key "${B}/leaves/client2a.example.org.key" \ - "https://127.0.0.1:${HTTPS_PORT}/production/catalog/client2a.example.org" >/dev/null + "https://127.0.0.1:${HTTPS_PORT}/puppet/v3/catalog/client2a.example.org?environment=production" >/dev/null echo } # set up the agent with the given fqdn set_up_agent() { local fqdn="$1" local agentdir="${B}/agent" mkdir -p "${agentdir}/conf" "${agentdir}/var" mkdir -p "${agentdir}/conf/ssl/private_keys" "${agentdir}/conf/ssl/certs" dedent > "${agentdir}/conf/puppet.conf" < [0,1] ).exit_code == 1 end end end diff --git a/acceptance/tests/security/cve-2013-4761_resource_type.rb b/acceptance/tests/security/cve-2013-4761_resource_type.rb index 24252a1d0..2cd7b2642 100644 --- a/acceptance/tests/security/cve-2013-4761_resource_type.rb +++ b/acceptance/tests/security/cve-2013-4761_resource_type.rb @@ -1,58 +1,58 @@ require 'puppet/acceptance/temp_file_utils' extend Puppet::Acceptance::TempFileUtils initialize_temp_dirs teardown do remove_temp_dirs end test_name "CVE 2013-4761 Remote code execution via REST resource_type" do confine :except, :platform => 'windows' create_test_file(master, 'auth.conf', <<-AUTH) path /resource_type method find, search auth any allow * AUTH create_remote_file(master, '/tmp/exploit.rb', <<-EXPLOIT) ::File.open('/tmp/exploited', 'w') { |f| f.puts("exploited") } EXPLOIT chmod(master, '777', '/tmp/exploit.rb') master_opts = { 'master' => { 'autosign' => true, 'rest_authconfig' => get_test_file_path(master, 'auth.conf'), }, } with_puppet_running_on(master, master_opts) do # Ensure each agent has a signed cert on agents, puppet("agent", "-t", "--server #{master}") agents.each do |agent| next if agent['roles'].include?('master') step "Ensure that the exploit marker is gone" do on master, "rm -f /tmp/exploited" end step "Request a type that maps to the exploit file" do type_name = "::..::..::..::..::..::tmp::exploit" - payload = "https://#{master}:8140/production/resource_type/#{type_name}" + payload = "https://#{master}:8140/puppet/v3/resource_type/#{type_name}?environment=production" cert_path = on(agent, puppet("agent", "--configprint hostcert")).stdout.chomp key_path = on(agent, puppet("agent", "--configprint hostprivkey")).stdout.chomp curl_base = "curl --tlsv1 -g --cert \"#{cert_path}\" --key \"#{key_path}\" -k -H 'Accept: pson'" on agent, "#{curl_base} '#{payload}'" end step "Check that the exploit marker was not created" do on master, "test ! -e /tmp/exploited" end end end end diff --git a/conf/auth.conf b/conf/auth.conf index e82cd3b63..2e9e916f5 100644 --- a/conf/auth.conf +++ b/conf/auth.conf @@ -1,124 +1,128 @@ # This is the default auth.conf file, which implements the default rules # used by the puppet master. (That is, the rules below will still apply # even if this file is deleted.) # # The ACLs are evaluated in top-down order. More specific stanzas should # be towards the top of the file and more general ones at the bottom; # otherwise, the general rules may "steal" requests that should be # governed by the specific rules. # # See http://docs.puppetlabs.com/guides/rest_auth_conf.html for a more complete # description of auth.conf's behavior. # # Supported syntax: # Each stanza in auth.conf starts with a path to match, followed # by optional modifiers, and finally, a series of allow or deny # directives. # # Example Stanza # --------------------------------- # path /path/to/resource # simple prefix match # # path ~ regex # alternately, regex match # [environment envlist] # [method methodlist] # [auth[enthicated] {yes|no|on|off|any}] # allow [host|backreference|*|regex] # deny [host|backreference|*|regex] # allow_ip [ip|cidr|ip_wildcard|*] # deny_ip [ip|cidr|ip_wildcard|*] # # The path match can either be a simple prefix match or a regular # expression. `path /file` would match both `/file_metadata` and # `/file_content`. Regex matches allow the use of backreferences # in the allow/deny directives. # # The regex syntax is the same as for Ruby regex, and captures backreferences # for use in the `allow` and `deny` lines of that stanza # # Examples: # -# path ~ ^/path/to/resource # Equivalent to `path /path/to/resource`. -# allow * # Allow all authenticated nodes (since auth -# # defaults to `yes`). +# path ~ ^/puppet/v3/path/to/resource # Equivalent to `path /puppet/v3/path/to/resource`. +# allow * # Allow all authenticated nodes (since auth +# # defaults to `yes`). # -# path ~ ^/catalog/([^/]+)$ # Permit nodes to access their own catalog (by -# allow $1 # certname), but not any other node's catalog. +# path ~ ^/puppet/v3/catalog/([^/]+)$ # Permit nodes to access their own catalog (by +# allow $1 # certname), but not any other node's catalog. # -# path ~ ^/file_(metadata|content)/extra_files/ # Only allow certain nodes to -# auth yes # access the "extra_files" -# allow /^(.+)\.example\.com$/ # mount point; note this must -# allow_ip 192.168.100.0/24 # go ABOVE the "/file" rule, -# # since it is more specific. +# path ~ ^/puppet/v3/file_(metadata|content)/extra_files/ # Only allow certain nodes to +# auth yes # access the "extra_files" +# allow /^(.+)\.example\.com$/ # mount point; note this must +# allow_ip 192.168.100.0/24 # go ABOVE the "/file" rule, +# # since it is more specific. # # environment:: restrict an ACL to a comma-separated list of environments # method:: restrict an ACL to a comma-separated list of HTTP methods # auth:: restrict an ACL to an authenticated or unauthenticated request # the default when unspecified is to restrict the ACL to authenticated requests # (ie exactly as if auth yes was present). # ### Authenticated ACLs - these rules apply only when the client ### has a valid certificate and is thus authenticated +path /puppet/v2.0/environments +method find +allow * + +path /puppet/v3/environments +method find +allow * + # allow nodes to retrieve their own catalog path ~ ^/puppet/v3/catalog/([^/]+)$ method find allow $1 # allow nodes to retrieve their own node definition path ~ ^/puppet/v3/node/([^/]+)$ method find allow $1 -# allow all nodes to access the certificates services -path /puppet/v3/certificate_revocation_list/ca -method find -allow * - # allow all nodes to store their own reports path ~ ^/puppet/v3/report/([^/]+)$ method save allow $1 # Allow all nodes to access all file services; this is necessary for # pluginsync, file serving from modules, and file serving from custom # mount points (see fileserver.conf). Note that the `/file` prefix matches # requests to both the file_metadata and file_content paths. See "Examples" # above if you need more granular access control for custom mount points. path /puppet/v3/file allow * +path /puppet/v3/status +method find +allow * + +# allow all nodes to access the certificates services +path /puppet-ca/v1/certificate_revocation_list/ca +method find +allow * + ### Unauthenticated ACLs, for clients without valid certificates; authenticated ### clients can also access these paths, though they rarely need to. # allow access to the CA certificate; unauthenticated nodes need this # in order to validate the puppet master's certificate -path /puppet/v3/certificate/ca +path /puppet-ca/v1/certificate/ca auth any method find allow * # allow nodes to retrieve the certificate they requested earlier -path /puppet/v3/certificate/ +path /puppet-ca/v1/certificate/ auth any method find allow * # allow nodes to request a new certificate -path /puppet/v3/certificate_request +path /puppet-ca/v1/certificate_request auth any method find, save allow * -path /puppet/v2.0/environments -method find -allow * - -path /puppet/v3/environments -method find -allow * - # deny everything else; this ACL is not strictly necessary, but # illustrates the default policy. path / auth any diff --git a/lib/puppet/indirector/rest.rb b/lib/puppet/indirector/rest.rb index 1c598a459..e86c78aa5 100644 --- a/lib/puppet/indirector/rest.rb +++ b/lib/puppet/indirector/rest.rb @@ -1,251 +1,250 @@ require 'net/http' require 'uri' require 'puppet/network/http' -require 'puppet/network/http/api/v3/indirected_routes' require 'puppet/network/http_pool' # Access objects via REST class Puppet::Indirector::REST < Puppet::Indirector::Terminus include Puppet::Network::HTTP::Compression.module - IndirectedRoutes = Puppet::Network::HTTP::API::V3::IndirectedRoutes + IndirectedRoutes = Puppet::Network::HTTP::API::IndirectedRoutes class << self attr_reader :server_setting, :port_setting end # Specify the setting that we should use to get the server name. def self.use_server_setting(setting) @server_setting = setting end # Specify the setting that we should use to get the port. def self.use_port_setting(setting) @port_setting = setting end # Specify the service to use when doing SRV record lookup def self.use_srv_service(service) @srv_service = service end def self.srv_service @srv_service || :puppet end def self.server Puppet.settings[server_setting || :server] end def self.port Puppet.settings[port_setting || :masterport].to_i end # Provide appropriate headers. def headers add_accept_encoding({"Accept" => model.supported_formats.join(", ")}) end def add_profiling_header(headers) if (Puppet[:profile]) headers[Puppet::Network::HTTP::HEADER_ENABLE_PROFILING] = "true" end headers end def network(request) Puppet::Network::HttpPool.http_instance(request.server || self.class.server, request.port || self.class.port) end def http_get(request, path, headers = nil, *args) http_request(:get, request, path, add_profiling_header(headers), *args) end def http_post(request, path, data, headers = nil, *args) http_request(:post, request, path, data, add_profiling_header(headers), *args) end def http_head(request, path, headers = nil, *args) http_request(:head, request, path, add_profiling_header(headers), *args) end def http_delete(request, path, headers = nil, *args) http_request(:delete, request, path, add_profiling_header(headers), *args) end def http_put(request, path, data, headers = nil, *args) http_request(:put, request, path, data, add_profiling_header(headers), *args) end def http_request(method, request, *args) conn = network(request) conn.send(method, *args) end def find(request) uri, body = IndirectedRoutes.request_to_uri_and_body(request) uri_with_query_string = "#{uri}?#{body}" response = do_request(request) do |req| # WEBrick in Ruby 1.9.1 only supports up to 1024 character lines in an HTTP request # http://redmine.ruby-lang.org/issues/show/3991 if "GET #{uri_with_query_string} HTTP/1.1\r\n".length > 1024 http_post(req, uri, body, headers) else http_get(req, uri_with_query_string, headers) end end if is_http_200?(response) content_type, body = parse_response(response) result = deserialize_find(content_type, body) result.name = request.key if result.respond_to?(:name=) result elsif is_http_404?(response) return nil unless request.options[:fail_on_404] # 404 can get special treatment as the indirector API can not produce a meaningful # reason to why something is not found - it may not be the thing the user is # expecting to find that is missing, but something else (like the environment). # While this way of handling the issue is not perfect, there is at least an error # that makes a user aware of the reason for the failure. # content_type, body = parse_response(response) msg = "Find #{elide(uri_with_query_string, 100)} resulted in 404 with the message: #{body}" raise Puppet::Error, msg else nil end end def head(request) response = do_request(request) do |req| http_head(req, IndirectedRoutes.request_to_uri(req), headers) end if is_http_200?(response) true else false end end def search(request) response = do_request(request) do |req| http_get(req, IndirectedRoutes.request_to_uri(req), headers) end if is_http_200?(response) content_type, body = parse_response(response) deserialize_search(content_type, body) || [] else [] end end def destroy(request) raise ArgumentError, "DELETE does not accept options" unless request.options.empty? response = do_request(request) do |req| http_delete(req, IndirectedRoutes.request_to_uri(req), headers) end if is_http_200?(response) content_type, body = parse_response(response) deserialize_destroy(content_type, body) else nil end end def save(request) raise ArgumentError, "PUT does not accept options" unless request.options.empty? response = do_request(request) do |req| http_put(req, IndirectedRoutes.request_to_uri(req), req.instance.render, headers.merge({ "Content-Type" => req.instance.mime })) end if is_http_200?(response) content_type, body = parse_response(response) deserialize_save(content_type, body) else nil end end # Encapsulate call to request.do_request with the arguments from this class # Then yield to the code block that was called in # We certainly could have retained the full request.do_request(...) { |r| ... } # but this makes the code much cleaner and we only then actually make the call # to request.do_request from here, thus if we change what we pass or how we # get it, we only need to change it here. def do_request(request) request.do_request(self.class.srv_service, self.class.server, self.class.port) { |req| yield(req) } end def validate_key(request) # Validation happens on the remote end end private def is_http_200?(response) case response.code when "404" false when /^2/ true else # Raise the http error if we didn't get a 'success' of some kind. raise convert_to_http_error(response) end end def is_http_404?(response) response.code == "404" end def convert_to_http_error(response) message = "Error #{response.code} on SERVER: #{(response.body||'').empty? ? response.message : uncompress_body(response)}" Net::HTTPError.new(message, response) end # Returns the content_type, stripping any appended charset, and the # body, decompressed if necessary (content-encoding is checked inside # uncompress_body) def parse_response(response) if response['content-type'] [ response['content-type'].gsub(/\s*;.*$/,''), body = uncompress_body(response) ] else raise "No content type in http response; cannot parse" end end def deserialize_find(content_type, body) model.convert_from(content_type, body) end def deserialize_search(content_type, body) model.convert_from_multiple(content_type, body) end def deserialize_destroy(content_type, body) model.convert_from(content_type, body) end def deserialize_save(content_type, body) nil end def elide(string, length) if Puppet::Util::Log.level == :debug || string.length <= length string else string[0, length - 3] + "..." end end end diff --git a/lib/puppet/network/authconfig.rb b/lib/puppet/network/authconfig.rb index 77c136bc3..d54ba498a 100644 --- a/lib/puppet/network/authconfig.rb +++ b/lib/puppet/network/authconfig.rb @@ -1,84 +1,95 @@ require 'puppet/network/rights' +require 'puppet/network/http' module Puppet class ConfigurationError < Puppet::Error; end class Network::AuthConfig attr_accessor :rights - def self.url_prefix - Puppet[:master_url_prefix] + def self.master_url_prefix + Puppet::Network::HTTP::MASTER_URL_PREFIX + end + + def self.ca_url_prefix + Puppet::Network::HTTP::CA_URL_PREFIX end def self.default_acl [ - # API V2.0 - { :acl => "#{url_prefix}/v2.0/environments", :method => :find, :allow => '*', :authenticated => true }, + # Master API V2.0 + { :acl => "#{master_url_prefix}/v2.0/environments", :method => :find, :allow => '*', :authenticated => true }, + + # Master API V3 + { :acl => "#{master_url_prefix}/v3/environments", :method => :find, :allow => '*', :authenticated => true }, + + { :acl => "~ ^#{master_url_prefix}\/v3\/catalog\/([^\/]+)$", :method => :find, :allow => '$1', :authenticated => true }, + { :acl => "~ ^#{master_url_prefix}\/v3\/node\/([^\/]+)$", :method => :find, :allow => '$1', :authenticated => true }, + { :acl => "~ ^#{master_url_prefix}\/v3\/report\/([^\/]+)$", :method => :save, :allow => '$1', :authenticated => true }, - # API V3 - { :acl => "~ ^#{url_prefix}\/v3\/catalog\/([^\/]+)$", :method => :find, :allow => '$1', :authenticated => true }, - { :acl => "~ ^#{url_prefix}\/v3\/node\/([^\/]+)$", :method => :find, :allow => '$1', :authenticated => true }, # this one will allow all file access, and thus delegate # to fileserver.conf - { :acl => "#{url_prefix}/v3/file" }, - { :acl => "#{url_prefix}/v3/certificate_revocation_list/ca", :method => :find, :authenticated => true }, - { :acl => "~ ^#{url_prefix}\/v3\/report\/([^\/]+)$", :method => :save, :allow => '$1', :authenticated => true }, + { :acl => "#{master_url_prefix}/v3/file" }, + + { :acl => "#{master_url_prefix}/v3/status", :method => [:find], :authenticated => true }, + + # CA API V1 + { :acl => "#{ca_url_prefix}/v1/certificate_revocation_list/ca", :method => :find, :authenticated => true }, + # These allow `auth any`, because if you can do them anonymously you # should probably also be able to do them when trusted. - { :acl => "#{url_prefix}/v3/certificate/ca", :method => :find, :authenticated => :any }, - { :acl => "#{url_prefix}/v3/certificate/", :method => :find, :authenticated => :any }, - { :acl => "#{url_prefix}/v3/certificate_request", :method => [:find, :save], :authenticated => :any }, - { :acl => "#{url_prefix}/v3/status", :method => [:find], :authenticated => true }, - { :acl => "#{url_prefix}/v3/environments", :method => :find, :allow => '*', :authenticated => true }, + { :acl => "#{ca_url_prefix}/v1/certificate/ca", :method => :find, :authenticated => :any }, + { :acl => "#{ca_url_prefix}/v1/certificate/", :method => :find, :authenticated => :any }, + { :acl => "#{ca_url_prefix}/v1/certificate_request", :method => [:find, :save], :authenticated => :any }, ] end # Just proxy the setting methods to our rights stuff [:allow, :deny].each do |method| define_method(method) do |*args| @rights.send(method, *args) end end # force regular ACLs to be present def insert_default_acl self.class.default_acl.each do |acl| unless rights[acl[:acl]] Puppet.info "Inserting default '#{acl[:acl]}' (auth #{acl[:authenticated]}) ACL" mk_acl(acl) end end # queue an empty (ie deny all) right for every other path # actually this is not strictly necessary as the rights system # denies not explicitly allowed paths unless rights["/"] rights.newright("/").restrict_authenticated(:any) end end def mk_acl(acl) right = @rights.newright(acl[:acl]) right.allow(acl[:allow] || "*") if method = acl[:method] method = [method] unless method.is_a?(Array) method.each { |m| right.restrict_method(m) } end right.restrict_authenticated(acl[:authenticated]) unless acl[:authenticated].nil? end # check whether this request is allowed in our ACL # raise an Puppet::Network::AuthorizedError if the request # is denied. def check_authorization(method, path, params) if authorization_failure_exception = @rights.is_request_forbidden_and_why?(method, path, params) Puppet.warning("Denying access: #{authorization_failure_exception}") raise authorization_failure_exception end end def initialize(rights=nil) @rights = rights || Puppet::Network::Rights.new insert_default_acl end end end diff --git a/lib/puppet/network/http.rb b/lib/puppet/network/http.rb index e3af2d6f1..0c700d630 100644 --- a/lib/puppet/network/http.rb +++ b/lib/puppet/network/http.rb @@ -1,22 +1,28 @@ module Puppet::Network::HTTP HEADER_ENABLE_PROFILING = "X-Puppet-Profiling" HEADER_PUPPET_VERSION = "X-Puppet-Version" + MASTER_URL_PREFIX = "/puppet" + CA_URL_PREFIX = "/puppet-ca" + require 'puppet/network/authorization' require 'puppet/network/http/issues' require 'puppet/network/http/error' require 'puppet/network/http/route' require 'puppet/network/http/api' - require 'puppet/network/http/api/v2' - require 'puppet/network/http/api/v3' + require 'puppet/network/http/api/ca' + require 'puppet/network/http/api/ca/v1' + require 'puppet/network/http/api/master' + require 'puppet/network/http/api/master/v2' + require 'puppet/network/http/api/master/v3' require 'puppet/network/http/handler' require 'puppet/network/http/response' require 'puppet/network/http/request' require 'puppet/network/http/site' require 'puppet/network/http/session' require 'puppet/network/http/factory' require 'puppet/network/http/nocache_pool' require 'puppet/network/http/pool' require 'puppet/network/http/memory_response' require 'puppet/network/http/compression' end diff --git a/lib/puppet/network/http/api.rb b/lib/puppet/network/http/api.rb index 623a641b4..ff74963e6 100644 --- a/lib/puppet/network/http/api.rb +++ b/lib/puppet/network/http/api.rb @@ -1,9 +1,26 @@ class Puppet::Network::HTTP::API def self.not_found Puppet::Network::HTTP::Route. path(/.*/). any(lambda do |req, res| raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("No route for #{req.method} #{req.path}", Puppet::Network::HTTP::Issues::HANDLER_NOT_FOUND) end) end + + def self.master_routes + master_prefix = Regexp.new("^#{Puppet::Network::HTTP::MASTER_URL_PREFIX}/") + Puppet::Network::HTTP::Route.path(master_prefix). + any. + chain(Puppet::Network::HTTP::API::Master::V3.routes, + Puppet::Network::HTTP::API::Master::V2.routes, + Puppet::Network::HTTP::API.not_found) + end + + def self.ca_routes + ca_prefix = Regexp.new("^#{Puppet::Network::HTTP::CA_URL_PREFIX}/") + Puppet::Network::HTTP::Route.path(ca_prefix). + any. + chain(Puppet::Network::HTTP::API::CA::V1.routes, + Puppet::Network::HTTP::API.not_found) + end end diff --git a/lib/puppet/network/http/api/ca.rb b/lib/puppet/network/http/api/ca.rb new file mode 100644 index 000000000..9e4828d6a --- /dev/null +++ b/lib/puppet/network/http/api/ca.rb @@ -0,0 +1,2 @@ +module Puppet::Network::HTTP::API::CA +end diff --git a/lib/puppet/network/http/api/ca/v1.rb b/lib/puppet/network/http/api/ca/v1.rb new file mode 100644 index 000000000..15a721eae --- /dev/null +++ b/lib/puppet/network/http/api/ca/v1.rb @@ -0,0 +1,11 @@ +require 'puppet/network/http/api/indirected_routes' +class Puppet::Network::HTTP::API::CA::V1 + + INDIRECTED = Puppet::Network::HTTP::Route. + path(/.*/). + any(Puppet::Network::HTTP::API::IndirectedRoutes.new) + + def self.routes + Puppet::Network::HTTP::Route.path(%r{v1}).any.chain(INDIRECTED) + end +end diff --git a/lib/puppet/network/http/api/v3/indirected_routes.rb b/lib/puppet/network/http/api/indirected_routes.rb similarity index 90% rename from lib/puppet/network/http/api/v3/indirected_routes.rb rename to lib/puppet/network/http/api/indirected_routes.rb index 8eba67e0f..9147d1ea5 100644 --- a/lib/puppet/network/http/api/v3/indirected_routes.rb +++ b/lib/puppet/network/http/api/indirected_routes.rb @@ -1,239 +1,248 @@ require 'puppet/network/authorization' +require 'puppet/network/http/api/indirection_type' -class Puppet::Network::HTTP::API::V3::IndirectedRoutes +class Puppet::Network::HTTP::API::IndirectedRoutes include Puppet::Network::Authorization # How we map http methods and the indirection name in the URI # to an indirection method. METHOD_MAP = { "GET" => { :plural => :search, :singular => :find }, "POST" => { :singular => :find, }, "PUT" => { :singular => :save }, "DELETE" => { :singular => :destroy }, "HEAD" => { :singular => :head } } + IndirectionType = Puppet::Network::HTTP::API::IndirectionType + def self.routes Puppet::Network::HTTP::Route.path(/.*/).any(new) end # handle an HTTP request def call(request, response) indirection, method, key, params = uri2indirection(request.method, request.path, request.params) certificate = request.client_cert if !indirection.allow_remote_requests? # TODO: should we tell the user we found an indirection but it doesn't # allow remote requests, or just pretend there's no handler at all? what # are the security implications for the former? raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("No handler for #{indirection.name}", :NO_INDIRECTION_REMOTE_REQUESTS) end trusted = Puppet::Context::TrustedInformation.remote(params[:authenticated], params[:node], certificate) Puppet.override(:trusted_information => trusted) do send("do_#{method}", indirection, key, params, request, response) end rescue Puppet::Network::HTTP::Error::HTTPError => e return do_http_control_exception(response, e) rescue StandardError => e return do_exception(response, e) end - def self.url_prefix - "#{Puppet[:master_url_prefix]}/v3" - end - def uri2indirection(http_method, uri, params) # the first field is always nil because of the leading slash, - indirection_name, key = uri.split("/", 5)[3..-1] + indirection_type, version, indirection_name, key = uri.split("/", 5)[1..-1] + url_prefix = "/#{indirection_type}/#{version}" environment = params.delete(:environment) if indirection_name !~ /^\w+$/ raise ArgumentError, "The indirection name must be purely alphanumeric, not '#{indirection_name}'" end + # this also depluralizes the indirection_name if it is a search method = indirection_method(http_method, indirection_name) - check_authorization(method, "#{self.class.url_prefix}/#{indirection_name}/#{key}", params) + + # check whether this indirection matches the prefix and version in the + # request + if url_prefix != IndirectionType.url_prefix_for(indirection_name) + raise ArgumentError, "Indirection '#{indirection_name}' does not match url prefix '#{url_prefix}'" + end + + check_authorization(method, "#{url_prefix}/#{indirection_name}/#{key}", params) indirection = Puppet::Indirector::Indirection.instance(indirection_name.to_sym) if !indirection raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new( "Could not find indirection '#{indirection_name}'", Puppet::Network::HTTP::Issues::HANDLER_NOT_FOUND) end if !environment raise ArgumentError, "An environment parameter must be specified" end if ! Puppet::Node::Environment.valid_name?(environment) raise ArgumentError, "The environment must be purely alphanumeric, not '#{environment}'" end configured_environment = Puppet.lookup(:environments).get(environment) if configured_environment.nil? raise ArgumentError, "Could not find environment '#{environment}'" else configured_environment = configured_environment.override_from_commandline(Puppet.settings) params[:environment] = configured_environment end params.delete(:bucket_path) if key == "" or key.nil? raise ArgumentError, "No request key specified in #{uri}" end key = URI.unescape(key) [indirection, method, key, params] end private def do_http_control_exception(response, exception) msg = exception.message Puppet.info(msg) response.respond_with(exception.status, "text/plain", msg) end def do_exception(response, exception, status=400) if exception.is_a?(Puppet::Network::AuthorizationError) # make sure we return the correct status code # for authorization issues status = 403 if status == 400 end Puppet.log_exception(exception) response.respond_with(status, "text/plain", exception.to_s) end # Execute our find. def do_find(indirection, key, params, request, response) unless result = indirection.find(key, params) raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find #{indirection.name} #{key}", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) end format = accepted_response_formatter_for(indirection.model, request) rendered_result = result if result.respond_to?(:render) Puppet::Util::Profiler.profile("Rendered result in #{format}", [:http, :v3_render, format]) do rendered_result = result.render(format) end end Puppet::Util::Profiler.profile("Sent response", [:http, :v3_response]) do response.respond_with(200, format, rendered_result) end end # Execute our head. def do_head(indirection, key, params, request, response) unless indirection.head(key, params) raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find #{indirection.name} #{key}", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) end # No need to set a response because no response is expected from a # HEAD request. All we need to do is not die. end # Execute our search. def do_search(indirection, key, params, request, response) result = indirection.search(key, params) if result.nil? raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find instances in #{indirection.name} with '#{key}'", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) end format = accepted_response_formatter_for(indirection.model, request) response.respond_with(200, format, indirection.model.render_multiple(format, result)) end # Execute our destroy. def do_destroy(indirection, key, params, request, response) formatter = accepted_response_formatter_or_pson_for(indirection.model, request) result = indirection.destroy(key, params) response.respond_with(200, formatter, formatter.render(result)) end # Execute our save. def do_save(indirection, key, params, request, response) formatter = accepted_response_formatter_or_pson_for(indirection.model, request) sent_object = read_body_into_model(indirection.model, request) result = indirection.save(sent_object, key) response.respond_with(200, formatter, formatter.render(result)) end def accepted_response_formatter_for(model_class, request) accepted_formats = request.headers['accept'] or raise Puppet::Network::HTTP::Error::HTTPNotAcceptableError.new("Missing required Accept header", Puppet::Network::HTTP::Issues::MISSING_HEADER_FIELD) request.response_formatter_for(model_class.supported_formats, accepted_formats) end def accepted_response_formatter_or_pson_for(model_class, request) accepted_formats = request.headers['accept'] || "text/pson" request.response_formatter_for(model_class.supported_formats, accepted_formats) end def read_body_into_model(model_class, request) data = request.body.to_s format = request.format model_class.convert_from(format, data) end def indirection_method(http_method, indirection) raise ArgumentError, "No support for http method #{http_method}" unless METHOD_MAP[http_method] unless method = METHOD_MAP[http_method][plurality(indirection)] raise ArgumentError, "No support for plurality #{plurality(indirection)} for #{http_method} operations" end method end def self.request_to_uri(request) uri, body = request_to_uri_and_body(request) "#{uri}?#{body}" end def self.request_to_uri_and_body(request) + url_prefix = IndirectionType.url_prefix_for(request.indirection_name.to_s) indirection = request.method == :search ? pluralize(request.indirection_name.to_s) : request.indirection_name.to_s ["#{url_prefix}/#{indirection}/#{request.escaped_key}", "environment=#{request.environment.name}&#{request.query_string}"] end def self.pluralize(indirection) return(indirection == "status" ? "statuses" : indirection + "s") end def plurality(indirection) # NOTE These specific hooks for paths are ridiculous, but it's a *many*-line # fix to not need this, and our goal is to move away from the complication # that leads to the fix being too long. return :singular if indirection == "status" return :singular if indirection == "certificate_status" result = (indirection =~ /s$|_search$/) ? :plural : :singular indirection.sub!(/s$|_search$/, '') indirection.sub!(/statuse$/, 'status') result end end diff --git a/lib/puppet/network/http/api/indirection_type.rb b/lib/puppet/network/http/api/indirection_type.rb new file mode 100644 index 000000000..712dffd62 --- /dev/null +++ b/lib/puppet/network/http/api/indirection_type.rb @@ -0,0 +1,32 @@ +class Puppet::Network::HTTP::API::IndirectionType + + INDIRECTION_TYPE_MAP = { + "certificate" => :ca, + "certificate_request" => :ca, + "certificate_revocation_list" => :ca, + "certificate_status" => :ca + } + + def self.master_url_prefix + "#{Puppet::Network::HTTP::MASTER_URL_PREFIX}/v3" + end + + def self.ca_url_prefix + "#{Puppet::Network::HTTP::CA_URL_PREFIX}/v1" + end + + def self.type_for(indirection) + INDIRECTION_TYPE_MAP[indirection] || :master + end + + def self.url_prefix_for(indirection_name) + case type_for(indirection_name) + when :ca + ca_url_prefix + when :master + master_url_prefix + else + raise ArgumentError, "Not a valid indirection type" + end + end +end diff --git a/lib/puppet/network/http/api/master.rb b/lib/puppet/network/http/api/master.rb new file mode 100644 index 000000000..aecebded2 --- /dev/null +++ b/lib/puppet/network/http/api/master.rb @@ -0,0 +1,2 @@ +module Puppet::Network::HTTP::API::Master +end diff --git a/lib/puppet/network/http/api/v2.rb b/lib/puppet/network/http/api/master/v2.rb similarity index 70% rename from lib/puppet/network/http/api/v2.rb rename to lib/puppet/network/http/api/master/v2.rb index f0412551d..c0f86285f 100644 --- a/lib/puppet/network/http/api/v2.rb +++ b/lib/puppet/network/http/api/master/v2.rb @@ -1,26 +1,26 @@ -module Puppet::Network::HTTP::API::V2 - require 'puppet/network/http/api/v2/environments' - require 'puppet/network/http/api/v2/authorization' +module Puppet::Network::HTTP::API::Master::V2 + require 'puppet/network/http/api/master/v2/environments' + require 'puppet/network/http/api/master/v2/authorization' def self.routes - path(%r{^/v2\.0}). + path(%r{^v2\.0}). get(Authorization.new). chain(ENVIRONMENTS, Puppet::Network::HTTP::API.not_found) end private def self.path(path) Puppet::Network::HTTP::Route.path(path) end def self.provide(&block) lambda do |request, response| block.call.call(request, response) end end ENVIRONMENTS = path(%r{^/environments$}).get(provide do Environments.new(Puppet.lookup(:environments)) end) end diff --git a/lib/puppet/network/http/api/v2/authorization.rb b/lib/puppet/network/http/api/master/v2/authorization.rb similarity index 90% rename from lib/puppet/network/http/api/v2/authorization.rb rename to lib/puppet/network/http/api/master/v2/authorization.rb index fff0da02f..4c9c41159 100644 --- a/lib/puppet/network/http/api/v2/authorization.rb +++ b/lib/puppet/network/http/api/master/v2/authorization.rb @@ -1,15 +1,15 @@ -class Puppet::Network::HTTP::API::V2::Authorization +class Puppet::Network::HTTP::API::Master::V2::Authorization include Puppet::Network::Authorization def call(request, response) if request.method != "GET" raise Puppet::Network::HTTP::Error::HTTPNotAuthorizedError.new("Only GET requests are authorized for V2 endpoints", Puppet::Network::HTTP::Issues::UNSUPPORTED_METHOD) end begin check_authorization(:find, request.path, request.params) rescue Puppet::Network::AuthorizationError => e raise Puppet::Network::HTTP::Error::HTTPNotAuthorizedError.new(e.message, Puppet::Network::HTTP::Issues::FAILED_AUTHORIZATION) end end end diff --git a/lib/puppet/network/http/api/v3/environments.rb b/lib/puppet/network/http/api/master/v2/environments.rb similarity index 92% rename from lib/puppet/network/http/api/v3/environments.rb rename to lib/puppet/network/http/api/master/v2/environments.rb index 06b58162a..92181ef4f 100644 --- a/lib/puppet/network/http/api/v3/environments.rb +++ b/lib/puppet/network/http/api/master/v2/environments.rb @@ -1,35 +1,35 @@ require 'json' -class Puppet::Network::HTTP::API::V3::Environments +class Puppet::Network::HTTP::API::Master::V2::Environments def initialize(env_loader) @env_loader = env_loader end def call(request, response) response.respond_with(200, "application/json", JSON.dump({ "search_paths" => @env_loader.search_paths, "environments" => Hash[@env_loader.list.collect do |env| [env.name, { "settings" => { "modulepath" => env.full_modulepath, "manifest" => env.manifest, "environment_timeout" => timeout(env), "config_version" => env.config_version || '', } }] end] })) end private def timeout(env) ttl = @env_loader.get_conf(env.name).environment_timeout if ttl == Float::INFINITY "unlimited" else ttl end end end diff --git a/lib/puppet/network/http/api/v3.rb b/lib/puppet/network/http/api/master/v3.rb similarity index 50% rename from lib/puppet/network/http/api/v3.rb rename to lib/puppet/network/http/api/master/v3.rb index e5240f2c2..87afc38f5 100644 --- a/lib/puppet/network/http/api/v3.rb +++ b/lib/puppet/network/http/api/master/v3.rb @@ -1,22 +1,22 @@ -class Puppet::Network::HTTP::API::V3 - require 'puppet/network/http/api/v3/authorization' - require 'puppet/network/http/api/v3/environments' - require 'puppet/network/http/api/v3/indirected_routes' +class Puppet::Network::HTTP::API::Master::V3 + require 'puppet/network/http/api/master/v3/authorization' + require 'puppet/network/http/api/master/v3/environments' + require 'puppet/network/http/api/indirected_routes' AUTHZ = Authorization.new INDIRECTED = Puppet::Network::HTTP::Route. path(/.*/). - any(Puppet::Network::HTTP::API::V3::IndirectedRoutes.new) + any(Puppet::Network::HTTP::API::IndirectedRoutes.new) ENVIRONMENTS = Puppet::Network::HTTP::Route. path(%r{^/environments$}).get(AUTHZ.wrap do Environments.new(Puppet.lookup(:environments)) end) def self.routes - Puppet::Network::HTTP::Route.path(%r{/v3}). + Puppet::Network::HTTP::Route.path(%r{v3}). any. chain(ENVIRONMENTS, INDIRECTED) end end diff --git a/lib/puppet/network/http/api/v3/authorization.rb b/lib/puppet/network/http/api/master/v3/authorization.rb similarity index 88% rename from lib/puppet/network/http/api/v3/authorization.rb rename to lib/puppet/network/http/api/master/v3/authorization.rb index a88453a0f..b9f5cf518 100644 --- a/lib/puppet/network/http/api/v3/authorization.rb +++ b/lib/puppet/network/http/api/master/v3/authorization.rb @@ -1,18 +1,18 @@ require 'puppet/network/authorization' -class Puppet::Network::HTTP::API::V3::Authorization +class Puppet::Network::HTTP::API::Master::V3::Authorization include Puppet::Network::Authorization def wrap(&block) lambda do |request, response| begin authconfig.check_authorization(:find, request.path, request.params) rescue Puppet::Network::AuthorizationError => e raise Puppet::Network::HTTP::Error::HTTPNotAuthorizedError.new(e.message, Puppet::Network::HTTP::Issues::FAILED_AUTHORIZATION) end block.call.call(request, response) end end end diff --git a/lib/puppet/network/http/api/v2/environments.rb b/lib/puppet/network/http/api/master/v3/environments.rb similarity index 92% rename from lib/puppet/network/http/api/v2/environments.rb rename to lib/puppet/network/http/api/master/v3/environments.rb index ab71f59f5..55593f960 100644 --- a/lib/puppet/network/http/api/v2/environments.rb +++ b/lib/puppet/network/http/api/master/v3/environments.rb @@ -1,35 +1,35 @@ require 'json' -class Puppet::Network::HTTP::API::V2::Environments +class Puppet::Network::HTTP::API::Master::V3::Environments def initialize(env_loader) @env_loader = env_loader end def call(request, response) response.respond_with(200, "application/json", JSON.dump({ "search_paths" => @env_loader.search_paths, "environments" => Hash[@env_loader.list.collect do |env| [env.name, { "settings" => { "modulepath" => env.full_modulepath, "manifest" => env.manifest, "environment_timeout" => timeout(env), "config_version" => env.config_version || '', } }] end] })) end private def timeout(env) ttl = @env_loader.get_conf(env.name).environment_timeout if ttl == Float::INFINITY "unlimited" else ttl end end end diff --git a/lib/puppet/network/http/rack/rest.rb b/lib/puppet/network/http/rack/rest.rb index e33f7a3e7..7eedf9857 100644 --- a/lib/puppet/network/http/rack/rest.rb +++ b/lib/puppet/network/http/rack/rest.rb @@ -1,141 +1,137 @@ require 'openssl' require 'cgi' require 'puppet/network/http/handler' require 'puppet/util/ssl' class Puppet::Network::HTTP::RackREST include Puppet::Network::HTTP::Handler ContentType = 'Content-Type'.freeze CHUNK_SIZE = 8192 class RackFile def initialize(file) @file = file end def each while chunk = @file.read(CHUNK_SIZE) yield chunk end end def close @file.close end end def initialize(args={}) super() - prefix = Regexp.new("^#{Puppet[:master_url_prefix]}") - register([Puppet::Network::HTTP::Route.path(prefix). - any. - chain(Puppet::Network::HTTP::API::V3.routes, - Puppet::Network::HTTP::API::V2.routes, - Puppet::Network::HTTP::API.not_found)]) + register([Puppet::Network::HTTP::API.master_routes, + Puppet::Network::HTTP::API.ca_routes]) end def set_content_type(response, format) response[ContentType] = format_to_mime(format) end # produce the body of the response def set_response(response, result, status = 200) response.status = status unless result.is_a?(File) response.write result else response["Content-Length"] = result.stat.size.to_s response.body = RackFile.new(result) end end # Retrieve all headers from the http request, as a map. def headers(request) headers = request.env.select {|k,v| k.start_with? 'HTTP_'}.inject({}) do |m, (k,v)| m[k.sub(/^HTTP_/, '').gsub('_','-').downcase] = v m end headers['content-type'] = request.content_type headers end # Return which HTTP verb was used in this request. def http_method(request) request.request_method end # Return the query params for this request. def params(request) if request.post? params = request.params else # rack doesn't support multi-valued query parameters, # e.g. ignore, so parse them ourselves params = CGI.parse(request.query_string) convert_singular_arrays_to_value(params) end result = decode_params(params) result.merge(extract_client_info(request)) end # what path was requested? (this is, without any query parameters) def path(request) request.path end # return the request body def body(request) request.body.read end def client_cert(request) # This environment variable is set by mod_ssl, note that it # requires the `+ExportCertData` option in the `SSLOptions` directive cert = request.env['SSL_CLIENT_CERT'] # NOTE: The SSL_CLIENT_CERT environment variable will be the empty string # when Puppet agent nodes have not yet obtained a signed certificate. if cert.nil? || cert.empty? nil else Puppet::SSL::Certificate.from_instance(OpenSSL::X509::Certificate.new(cert)) end end # Passenger freaks out if we finish handling the request without reading any # part of the body, so make sure we have. def cleanup(request) request.body.read(1) nil end def extract_client_info(request) result = {} result[:ip] = request.ip # if we find SSL info in the headers, use them to get a hostname from the CN. # try this with :ssl_client_header, which defaults should work for # Apache with StdEnvVars. subj_str = request.env[Puppet[:ssl_client_header]] subject = Puppet::Util::SSL.subject_from_dn(subj_str || "") if cn = Puppet::Util::SSL.cn_from_subject(subject) result[:node] = cn result[:authenticated] = (request.env[Puppet[:ssl_client_verify_header]] == 'SUCCESS') else result[:node] = resolve_node(result) result[:authenticated] = false end result end def convert_singular_arrays_to_value(hash) hash.each do |key, value| if value.size == 1 hash[key] = value.first end end end end diff --git a/lib/puppet/network/http/webrick/rest.rb b/lib/puppet/network/http/webrick/rest.rb index 3056b8152..8a6815f6b 100644 --- a/lib/puppet/network/http/webrick/rest.rb +++ b/lib/puppet/network/http/webrick/rest.rb @@ -1,118 +1,113 @@ require 'puppet/network/http/handler' -require 'puppet/network/http/api/v3' require 'resolv' require 'webrick' require 'webrick/httputils' require 'puppet/util/ssl' class Puppet::Network::HTTP::WEBrickREST < WEBrick::HTTPServlet::AbstractServlet include Puppet::Network::HTTP::Handler def self.mutex @mutex ||= Mutex.new end def initialize(server) raise ArgumentError, "server is required" unless server - prefix = Regexp.new("^#{Puppet[:master_url_prefix]}") - register([Puppet::Network::HTTP::Route.path(prefix). - any. - chain(Puppet::Network::HTTP::API::V3.routes, - Puppet::Network::HTTP::API::V2.routes, - Puppet::Network::HTTP::API.not_found)]) + register([Puppet::Network::HTTP::API.master_routes, + Puppet::Network::HTTP::API.ca_routes]) super(server) end # Retrieve the request parameters, including authentication information. def params(request) query = request.query || {} params = if request.request_method == "PUT" # webrick doesn't look at the query string for PUT requests, it only # looks at the body, and then only if the body has a content type that # looks like url-encoded form data. We need the query string data as well. WEBrick::HTTPUtils.parse_query(request.query_string).merge(query) else query end params = Hash[params.collect do |key, value| all_values = value.list [key, all_values.length == 1 ? value : all_values] end] params = decode_params(params) params.merge(client_information(request)) end # WEBrick uses a service method to respond to requests. Simply delegate to # the handler response method. def service(request, response) self.class.mutex.synchronize do process(request, response) end end def headers(request) result = {} request.each do |k, v| result[k.downcase] = v end result end def http_method(request) request.request_method end def path(request) request.path end def body(request) request.body end def client_cert(request) if cert = request.client_cert Puppet::SSL::Certificate.from_instance(cert) else nil end end # Set the specified format as the content type of the response. def set_content_type(response, format) response["content-type"] = format_to_mime(format) end def set_response(response, result, status = 200) response.status = status if status >= 200 and status != 304 response.body = result response["content-length"] = result.stat.size if result.is_a?(File) end end # Retrieve node/cert/ip information from the request object. def client_information(request) result = {} if peer = request.peeraddr and ip = peer[3] result[:ip] = ip end # If they have a certificate (which will almost always be true) # then we get the hostname from the cert, instead of via IP # info result[:authenticated] = false if cert = request.client_cert and cn = Puppet::Util::SSL.cn_from_subject(cert.subject) result[:node] = cn result[:authenticated] = true else result[:node] = resolve_node(result) end result end end diff --git a/lib/puppet/type/file/content.rb b/lib/puppet/type/file/content.rb index 02cea22df..77bda913d 100644 --- a/lib/puppet/type/file/content.rb +++ b/lib/puppet/type/file/content.rb @@ -1,242 +1,242 @@ require 'net/http' require 'uri' require 'tempfile' require 'puppet/util/checksums' require 'puppet/network/http' -require 'puppet/network/http/api/v3/indirected_routes' +require 'puppet/network/http/api/indirected_routes' require 'puppet/network/http/compression' module Puppet Puppet::Type.type(:file).newproperty(:content) do include Puppet::Util::Diff include Puppet::Util::Checksums include Puppet::Network::HTTP::Compression.module attr_reader :actual_content desc <<-'EOT' The desired contents of a file, as a string. This attribute is mutually exclusive with `source` and `target`. Newlines and tabs can be specified in double-quoted strings using standard escaped syntax --- \n for a newline, and \t for a tab. With very small files, you can construct content strings directly in the manifest... define resolve(nameserver1, nameserver2, domain, search) { $str = "search $search domain $domain nameserver $nameserver1 nameserver $nameserver2 " file { "/etc/resolv.conf": content => "$str", } } ...but for larger files, this attribute is more useful when combined with the [template](http://docs.puppetlabs.com/references/latest/function.html#template) or [file](http://docs.puppetlabs.com/references/latest/function.html#file) function. EOT # Store a checksum as the value, rather than the actual content. # Simplifies everything. munge do |value| if value == :absent value elsif checksum?(value) # XXX This is potentially dangerous because it means users can't write a file whose # entire contents are a plain checksum value else @actual_content = value resource.parameter(:checksum).sum(value) end end # Checksums need to invert how changes are printed. def change_to_s(currentvalue, newvalue) # Our "new" checksum value is provided by the source. if source = resource.parameter(:source) and tmp = source.checksum newvalue = tmp end if currentvalue == :absent return "defined content as '#{newvalue}'" elsif newvalue == :absent return "undefined content from '#{currentvalue}'" else return "content changed '#{currentvalue}' to '#{newvalue}'" end end def checksum_type if source = resource.parameter(:source) result = source.checksum else result = resource[:checksum] end if result =~ /^\{(\w+)\}.+/ return $1.to_sym else return result end end def length (actual_content and actual_content.length) || 0 end def content self.should end # Override this method to provide diffs if asked for. # Also, fix #872: when content is used, and replace is true, the file # should be insync when it exists def insync?(is) if resource.should_be_file? return false if is == :absent else if resource[:ensure] == :present and resource[:content] and s = resource.stat resource.warning "Ensure set to :present but file type is #{s.ftype} so no content will be synced" end return true end return true if ! @resource.replace? result = super if ! result and Puppet[:show_diff] and resource.show_diff? write_temporarily do |path| send @resource[:loglevel], "\n" + diff(@resource[:path], path) end end result end def retrieve return :absent unless stat = @resource.stat ftype = stat.ftype # Don't even try to manage the content on directories or links return nil if ["directory","link"].include?(ftype) begin resource.parameter(:checksum).sum_file(resource[:path]) rescue => detail raise Puppet::Error, "Could not read #{ftype} #{@resource.title}: #{detail}", detail.backtrace end end # Make sure we're also managing the checksum property. def should=(value) # treat the value as a bytestring, in Ruby versions that support it, regardless of the encoding # in which it has been supplied value = value.dup.force_encoding(Encoding::ASCII_8BIT) if value.respond_to?(:force_encoding) @resource.newattr(:checksum) unless @resource.parameter(:checksum) super end # Just write our content out to disk. def sync return_event = @resource.stat ? :file_changed : :file_created # We're safe not testing for the 'source' if there's no 'should' # because we wouldn't have gotten this far if there weren't at least # one valid value somewhere. @resource.write(:content) return_event end def write_temporarily tempfile = Tempfile.new("puppet-file") tempfile.open write(tempfile) tempfile.close yield tempfile.path tempfile.delete end def write(file) resource.parameter(:checksum).sum_stream { |sum| each_chunk_from(actual_content || resource.parameter(:source)) { |chunk| sum << chunk file.print chunk } } end # the content is munged so if it's a checksum source_or_content is nil # unless the checksum indirectly comes from source def each_chunk_from(source_or_content) if source_or_content.is_a?(String) yield source_or_content elsif content_is_really_a_checksum? && source_or_content.nil? yield read_file_from_filebucket elsif source_or_content.nil? yield '' elsif Puppet[:default_file_terminus] == :file_server yield source_or_content.content elsif source_or_content.local? chunk_file_from_disk(source_or_content) { |chunk| yield chunk } else chunk_file_from_source(source_or_content) { |chunk| yield chunk } end end private def content_is_really_a_checksum? checksum?(should) end def chunk_file_from_disk(source_or_content) File.open(source_or_content.full_path, "rb") do |src| while chunk = src.read(8192) yield chunk end end end def get_from_source(source_or_content, &block) source = source_or_content.metadata.source request = Puppet::Indirector::Request.new(:file_content, :find, source, nil, :environment => resource.catalog.environment_instance) request.do_request(:fileserver) do |req| connection = Puppet::Network::HttpPool.http_instance(req.server, req.port) - connection.request_get(Puppet::Network::HTTP::API::V3::IndirectedRoutes.request_to_uri(req), add_accept_encoding({"Accept" => "raw"}), &block) + connection.request_get(Puppet::Network::HTTP::API::IndirectedRoutes.request_to_uri(req), add_accept_encoding({"Accept" => "raw"}), &block) end end def chunk_file_from_source(source_or_content) get_from_source(source_or_content) do |response| case response.code when /^2/; uncompress(response) { |uncompressor| response.read_body { |chunk| yield uncompressor.uncompress(chunk) } } else # Raise the http error if we didn't get a 'success' of some kind. message = "Error #{response.code} on SERVER: #{(response.body||'').empty? ? response.message : uncompress_body(response)}" raise Net::HTTPError.new(message, response) end end end def read_file_from_filebucket raise "Could not get filebucket from file" unless dipper = resource.bucket sum = should.sub(/\{\w+\}/, '') dipper.getfile(sum) rescue => detail self.fail Puppet::Error, "Could not retrieve content for #{should} from filebucket: #{detail}", detail end end end diff --git a/spec/lib/puppet_spec/handler.rb b/spec/lib/puppet_spec/handler.rb new file mode 100644 index 000000000..274952dcf --- /dev/null +++ b/spec/lib/puppet_spec/handler.rb @@ -0,0 +1,53 @@ +require 'puppet/network/http/handler' + +class PuppetSpec::Handler + include Puppet::Network::HTTP::Handler + + def initialize(* routes) + register(routes) + end + + def set_content_type(response, format) + response[:content_type_header] = format + end + + def set_response(response, body, status = 200) + response[:body] = body + response[:status] = status + end + + def http_method(request) + request[:method] + end + + def path(request) + request[:path] + end + + def params(request) + request[:params] + end + + def client_cert(request) + request[:client_cert] + end + + def body(request) + request[:body] + end + + def headers(request) + request[:headers] || {} + end +end + +class PuppetSpec::HandlerProfiler + def start(metric, description) + end + + def finish(context, metric, description) + end + + def shutdown() + end +end diff --git a/spec/unit/indirector/rest_spec.rb b/spec/unit/indirector/rest_spec.rb index 80a47fe03..18266d632 100755 --- a/spec/unit/indirector/rest_spec.rb +++ b/spec/unit/indirector/rest_spec.rb @@ -1,574 +1,574 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/indirector' require 'puppet/indirector/errors' require 'puppet/indirector/rest' require 'puppet/util/psych_support' HTTP_ERROR_CODES = [300, 400, 500] # Just one from each category since the code makes no real distinctions shared_examples_for "a REST terminus method" do |terminus_method| HTTP_ERROR_CODES.each do |code| describe "when the response code is #{code}" do let(:response) { mock_response(code, 'error messaged!!!') } it "raises an http error with the body of the response" do expect { terminus.send(terminus_method, request) }.to raise_error(Net::HTTPError, "Error #{code} on SERVER: #{response.body}") end it "does not attempt to deserialize the response" do model.expects(:convert_from).never expect { terminus.send(terminus_method, request) }.to raise_error(Net::HTTPError) end # I'm not sure what this means or if it's used it "if the body is empty raises an http error with the response header" do response.stubs(:body).returns "" response.stubs(:message).returns "fhqwhgads" expect { terminus.send(terminus_method, request) }.to raise_error(Net::HTTPError, "Error #{code} on SERVER: #{response.message}") end describe "and the body is compressed" do it "raises an http error with the decompressed body of the response" do uncompressed_body = "why" compressed_body = Zlib::Deflate.deflate(uncompressed_body) response = mock_response(code, compressed_body, 'text/plain', 'deflate') connection.expects(http_method).returns(response) expect { terminus.send(terminus_method, request) }.to raise_error(Net::HTTPError, "Error #{code} on SERVER: #{uncompressed_body}") end end end end end shared_examples_for "a deserializing terminus method" do |terminus_method| describe "when the response has no content-type" do let(:response) { mock_response(200, "body", nil, nil) } it "raises an error" do expect { terminus.send(terminus_method, request) }.to raise_error(RuntimeError, "No content type in http response; cannot parse") end end it "doesn't catch errors in deserialization" do model.expects(:convert_from).raises(Puppet::Error, "Whoa there") expect { terminus.send(terminus_method, request) }.to raise_error(Puppet::Error, "Whoa there") end end describe Puppet::Indirector::REST do before :all do class Puppet::TestModel include Puppet::Util::PsychSupport extend Puppet::Indirector indirects :test_model attr_accessor :name, :data def initialize(name = "name", data = '') @name = name @data = data end def self.convert_from(format, string) new('', string) end def self.convert_from_multiple(format, string) string.split(',').collect { |s| convert_from(format, s) } end def to_data_hash { 'name' => @name, 'data' => @data } end def ==(other) other.is_a? Puppet::TestModel and other.name == name and other.data == data end end # The subclass must not be all caps even though the superclass is class Puppet::TestModel::Rest < Puppet::Indirector::REST end Puppet::TestModel.indirection.terminus_class = :rest end after :all do Puppet::TestModel.indirection.delete # Remove the class, unlinking it from the rest of the system. Puppet.send(:remove_const, :TestModel) end let(:terminus_class) { Puppet::TestModel::Rest } let(:terminus) { Puppet::TestModel.indirection.terminus(:rest) } let(:indirection) { Puppet::TestModel.indirection } let(:model) { Puppet::TestModel } - let(:url_prefix) { "#{Puppet[:master_url_prefix]}/v3"} + let(:url_prefix) { "#{Puppet::Network::HTTP::MASTER_URL_PREFIX}/v3"} around(:each) do |example| Puppet.override(:current_environment => Puppet::Node::Environment.create(:production, [])) do example.run end end def mock_response(code, body, content_type='text/plain', encoding=nil) obj = stub('http 200 ok', :code => code.to_s, :body => body) obj.stubs(:[]).with('content-type').returns(content_type) obj.stubs(:[]).with('content-encoding').returns(encoding) obj.stubs(:[]).with(Puppet::Network::HTTP::HEADER_PUPPET_VERSION).returns(Puppet.version) obj end def find_request(key, options={}) Puppet::Indirector::Request.new(:test_model, :find, key, nil, options) end def head_request(key, options={}) Puppet::Indirector::Request.new(:test_model, :head, key, nil, options) end def search_request(key, options={}) Puppet::Indirector::Request.new(:test_model, :search, key, nil, options) end def delete_request(key, options={}) Puppet::Indirector::Request.new(:test_model, :destroy, key, nil, options) end def save_request(key, instance, options={}) Puppet::Indirector::Request.new(:test_model, :save, key, instance, options) end it "should have a method for specifying what setting a subclass should use to retrieve its server" do terminus_class.should respond_to(:use_server_setting) end it "should use any specified setting to pick the server" do terminus_class.expects(:server_setting).returns :ca_server Puppet[:ca_server] = "myserver" terminus_class.server.should == "myserver" end it "should default to :server for the server setting" do terminus_class.expects(:server_setting).returns nil Puppet[:server] = "myserver" terminus_class.server.should == "myserver" end it "should have a method for specifying what setting a subclass should use to retrieve its port" do terminus_class.should respond_to(:use_port_setting) end it "should use any specified setting to pick the port" do terminus_class.expects(:port_setting).returns :ca_port Puppet[:ca_port] = "321" terminus_class.port.should == 321 end it "should default to :port for the port setting" do terminus_class.expects(:port_setting).returns nil Puppet[:masterport] = "543" terminus_class.port.should == 543 end it 'should default to :puppet for the srv_service' do Puppet::Indirector::REST.srv_service.should == :puppet end describe "when creating an HTTP client" do it "should use the class's server and port if the indirection request provides neither" do @request = stub 'request', :key => "foo", :server => nil, :port => nil terminus.class.expects(:port).returns 321 terminus.class.expects(:server).returns "myserver" Puppet::Network::HttpPool.expects(:http_instance).with("myserver", 321).returns "myconn" terminus.network(@request).should == "myconn" end it "should use the server from the indirection request if one is present" do @request = stub 'request', :key => "foo", :server => "myserver", :port => nil terminus.class.stubs(:port).returns 321 Puppet::Network::HttpPool.expects(:http_instance).with("myserver", 321).returns "myconn" terminus.network(@request).should == "myconn" end it "should use the port from the indirection request if one is present" do @request = stub 'request', :key => "foo", :server => nil, :port => 321 terminus.class.stubs(:server).returns "myserver" Puppet::Network::HttpPool.expects(:http_instance).with("myserver", 321).returns "myconn" terminus.network(@request).should == "myconn" end end describe "#find" do let(:http_method) { :get } let(:response) { mock_response(200, 'body') } let(:connection) { stub('mock http connection', :get => response, :verify_callback= => nil) } let(:request) { find_request('foo') } before :each do terminus.stubs(:network).returns(connection) end it_behaves_like 'a REST terminus method', :find it_behaves_like 'a deserializing terminus method', :find describe "with a long set of parameters" do it "calls post on the connection with the query params in the body" do params = {} 'aa'.upto('zz') do |s| params[s] = 'foo' end # The request special-cases this parameter, and it # won't be passed on to the server, so we remove it here # to avoid a failure. params.delete('ip') params["environment"] = "production" request = find_request('whoa', params) connection.expects(:post).with do |uri, body| body.split("&").sort == params.map {|key,value| "#{key}=#{value}"}.sort end.returns(mock_response(200, 'body')) terminus.find(request) end end describe "with no parameters" do it "calls get on the connection" do request = find_request('foo bar') connection.expects(:get).with("#{url_prefix}/test_model/foo%20bar?environment=production&", anything).returns(mock_response('200', 'response body')) terminus.find(request).should == model.new('foo bar', 'response body') end end it "returns nil on 404" do response = mock_response('404', nil) connection.expects(:get).returns(response) terminus.find(request).should == nil end it 'raises no warning for a 404 (when not asked to do so)' do response = mock_response('404', 'this is the notfound you are looking for') connection.expects(:get).returns(response) expect{terminus.find(request)}.to_not raise_error() end context 'when fail_on_404 is used in request' do it 'raises an error for a 404 when asked to do so' do request = find_request('foo', :fail_on_404 => true) response = mock_response('404', 'this is the notfound you are looking for') connection.expects(:get).returns(response) expect do terminus.find(request) end.to raise_error( Puppet::Error, "Find #{url_prefix}/test_model/foo?environment=production&fail_on_404=true resulted in 404 with the message: this is the notfound you are looking for") end it 'truncates the URI when it is very long' do request = find_request('foo', :fail_on_404 => true, :long_param => ('A' * 100) + 'B') response = mock_response('404', 'this is the notfound you are looking for') connection.expects(:get).returns(response) expect do terminus.find(request) end.to raise_error( Puppet::Error, /\/test_model\/foo.*\?environment=production&.*long_param=A+\.\.\..*resulted in 404 with the message/) end it 'does not truncate the URI when logging debug information' do Puppet.debug = true request = find_request('foo', :fail_on_404 => true, :long_param => ('A' * 100) + 'B') response = mock_response('404', 'this is the notfound you are looking for') connection.expects(:get).returns(response) expect do terminus.find(request) end.to raise_error( Puppet::Error, /\/test_model\/foo.*\?environment=production&.*long_param=A+B.*resulted in 404 with the message/) end end it "asks the model to deserialize the response body and sets the name on the resulting object to the find key" do connection.expects(:get).returns response model.expects(:convert_from).with(response['content-type'], response.body).returns( model.new('overwritten', 'decoded body') ) terminus.find(request).should == model.new('foo', 'decoded body') end it "doesn't require the model to support name=" do connection.expects(:get).returns response instance = model.new('name', 'decoded body') model.expects(:convert_from).with(response['content-type'], response.body).returns(instance) instance.expects(:respond_to?).with(:name=).returns(false) instance.expects(:name=).never terminus.find(request).should == model.new('name', 'decoded body') end it "provides an Accept header containing the list of supported formats joined with commas" do connection.expects(:get).with(anything, has_entry("Accept" => "supported, formats")).returns(response) terminus.model.expects(:supported_formats).returns %w{supported formats} terminus.find(request) end it "adds an Accept-Encoding header" do terminus.expects(:add_accept_encoding).returns({"accept-encoding" => "gzip"}) connection.expects(:get).with(anything, has_entry("accept-encoding" => "gzip")).returns(response) terminus.find(request) end it "uses only the mime-type from the content-type header when asking the model to deserialize" do response = mock_response('200', 'mydata', "text/plain; charset=utf-8") connection.expects(:get).returns(response) model.expects(:convert_from).with("text/plain", "mydata").returns "myobject" terminus.find(request).should == "myobject" end it "decompresses the body before passing it to the model for deserialization" do uncompressed_body = "Why hello there" compressed_body = Zlib::Deflate.deflate(uncompressed_body) response = mock_response('200', compressed_body, 'text/plain', 'deflate') connection.expects(:get).returns(response) model.expects(:convert_from).with("text/plain", uncompressed_body).returns "myobject" terminus.find(request).should == "myobject" end end describe "#head" do let(:http_method) { :head } let(:response) { mock_response(200, nil) } let(:connection) { stub('mock http connection', :head => response, :verify_callback= => nil) } let(:request) { head_request('foo') } before :each do terminus.stubs(:network).returns(connection) end it_behaves_like 'a REST terminus method', :head it "returns true if there was a successful http response" do connection.expects(:head).returns mock_response('200', nil) terminus.head(request).should == true end it "returns false on a 404 response" do connection.expects(:head).returns mock_response('404', nil) terminus.head(request).should == false end end describe "#search" do let(:http_method) { :get } let(:response) { mock_response(200, 'data1,data2,data3') } let(:connection) { stub('mock http connection', :get => response, :verify_callback= => nil) } let(:request) { search_request('foo') } before :each do terminus.stubs(:network).returns(connection) end it_behaves_like 'a REST terminus method', :search it_behaves_like 'a deserializing terminus method', :search it "should call the GET http method on a network connection" do connection.expects(:get).with("#{url_prefix}/test_models/foo?environment=production&", has_key('Accept')).returns mock_response(200, 'data3, data4') terminus.search(request) end it "returns an empty list on 404" do response = mock_response('404', nil) connection.expects(:get).returns(response) terminus.search(request).should == [] end it "asks the model to deserialize the response body into multiple instances" do terminus.search(request).should == [model.new('', 'data1'), model.new('', 'data2'), model.new('', 'data3')] end it "should provide an Accept header containing the list of supported formats joined with commas" do connection.expects(:get).with(anything, has_entry("Accept" => "supported, formats")).returns(mock_response(200, '')) terminus.model.expects(:supported_formats).returns %w{supported formats} terminus.search(request) end it "should return an empty array if serialization returns nil" do model.stubs(:convert_from_multiple).returns nil terminus.search(request).should == [] end end describe "#destroy" do let(:http_method) { :delete } let(:response) { mock_response(200, 'body') } let(:connection) { stub('mock http connection', :delete => response, :verify_callback= => nil) } let(:request) { delete_request('foo') } before :each do terminus.stubs(:network).returns(connection) end it_behaves_like 'a REST terminus method', :destroy it_behaves_like 'a deserializing terminus method', :destroy it "should call the DELETE http method on a network connection" do connection.expects(:delete).with("#{url_prefix}/test_model/foo?environment=production&", has_key('Accept')).returns(response) terminus.destroy(request) end it "should fail if any options are provided, since DELETE apparently does not support query options" do request = delete_request('foo', :one => "two", :three => "four") expect { terminus.destroy(request) }.to raise_error(ArgumentError) end it "should deserialize and return the http response" do connection.expects(:delete).returns response terminus.destroy(request).should == model.new('', 'body') end it "returns nil on 404" do response = mock_response('404', nil) connection.expects(:delete).returns(response) terminus.destroy(request).should == nil end it "should provide an Accept header containing the list of supported formats joined with commas" do connection.expects(:delete).with(anything, has_entry("Accept" => "supported, formats")).returns(response) terminus.model.expects(:supported_formats).returns %w{supported formats} terminus.destroy(request) end end describe "#save" do let(:http_method) { :put } let(:response) { mock_response(200, 'body') } let(:connection) { stub('mock http connection', :put => response, :verify_callback= => nil) } let(:instance) { model.new('the thing', 'some contents') } let(:request) { save_request(instance.name, instance) } before :each do terminus.stubs(:network).returns(connection) end it_behaves_like 'a REST terminus method', :save it "should call the PUT http method on a network connection" do connection.expects(:put).with("#{url_prefix}/test_model/the%20thing?environment=production&", anything, has_key("Content-Type")).returns response terminus.save(request) end it "should fail if any options are provided, since PUT apparently does not support query options" do request = save_request(instance.name, instance, :one => "two", :three => "four") expect { terminus.save(request) }.to raise_error(ArgumentError) end it "should serialize the instance using the default format and pass the result as the body of the request" do instance.expects(:render).returns "serial_instance" connection.expects(:put).with(anything, "serial_instance", anything).returns response terminus.save(request) end it "returns nil on 404" do response = mock_response('404', nil) connection.expects(:put).returns(response) terminus.save(request).should == nil end it "returns nil" do connection.expects(:put).returns response terminus.save(request).should be_nil end it "should provide an Accept header containing the list of supported formats joined with commas" do connection.expects(:put).with(anything, anything, has_entry("Accept" => "supported, formats")).returns(response) instance.expects(:render).returns('') model.expects(:supported_formats).returns %w{supported formats} instance.expects(:mime).returns "supported" terminus.save(request) end it "should provide a Content-Type header containing the mime-type of the sent object" do instance.expects(:mime).returns "mime" connection.expects(:put).with(anything, anything, has_entry('Content-Type' => "mime")).returns(response) terminus.save(request) end end context 'dealing with SRV settings' do [ :destroy, :find, :head, :save, :search ].each do |method| it "##{method} passes the SRV service, and fall-back server & port to the request's do_request method" do request = Puppet::Indirector::Request.new(:indirection, method, 'key', nil) stub_response = mock_response('200', 'body') request.expects(:do_request).with(terminus.class.srv_service, terminus.class.server, terminus.class.port).returns(stub_response) terminus.send(method, request) end end end end diff --git a/spec/unit/network/authconfig_spec.rb b/spec/unit/network/authconfig_spec.rb index f9e87633c..3795ed4a7 100755 --- a/spec/unit/network/authconfig_spec.rb +++ b/spec/unit/network/authconfig_spec.rb @@ -1,109 +1,109 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/network/authconfig' describe Puppet::Network::AuthConfig do before :each do Puppet::FileSystem.stubs(:stat).returns stub('stat', :ctime => :now) Time.stubs(:now).returns Time.now Puppet::Network::AuthConfig.any_instance.stubs(:exists?).returns(true) # FIXME @authconfig = Puppet::Network::AuthConfig.new("dummy") end describe "when initializing" do it "inserts default ACLs after setting initial rights" do Puppet::Network::AuthConfig.any_instance.expects(:insert_default_acl) Puppet::Network::AuthConfig.new end end describe "when defining an acl with mk_acl" do before :each do Puppet::Network::AuthConfig.any_instance.stubs(:insert_default_acl) @authconfig = Puppet::Network::AuthConfig.new end it "should create a new right for each default acl" do @authconfig.mk_acl(:acl => '/') @authconfig.rights['/'].should be end it "allows everyone for each default right" do @authconfig.mk_acl(:acl => '/') @authconfig.rights['/'].should be_globalallow end it "accepts an argument to restrict the method" do @authconfig.mk_acl(:acl => '/', :method => :find) @authconfig.rights['/'].methods.should == [:find] end it "creates rights with authentication set to true by default" do @authconfig.mk_acl(:acl => '/') @authconfig.rights['/'].authentication.should be_true end it "accepts an argument to set the authentication requirement" do @authconfig.mk_acl(:acl => '/', :authenticated => :any) @authconfig.rights['/'].authentication.should be_false end end describe "when adding default ACLs" do before :each do Puppet::Network::AuthConfig.any_instance.stubs(:insert_default_acl) @authconfig = Puppet::Network::AuthConfig.new Puppet::Network::AuthConfig.any_instance.unstub(:insert_default_acl) end Puppet::Network::AuthConfig::default_acl.each do |acl| it "should create a default right for #{acl[:acl]}" do @authconfig.stubs(:mk_acl) @authconfig.expects(:mk_acl).with(acl) @authconfig.insert_default_acl end end it "should log at info loglevel" do Puppet.expects(:info).at_least_once @authconfig.insert_default_acl end it "creates an empty catch-all rule for '/' for any authentication request state" do @authconfig.stubs(:mk_acl) @authconfig.insert_default_acl @authconfig.rights['/'].should be_empty @authconfig.rights['/'].authentication.should be_false end it '(CVE-2013-2275) allows report submission only for the node matching the certname by default' do acl = { - :acl => "~ ^#{Puppet[:master_url_prefix]}\/v3\/report\/([^\/]+)$", + :acl => "~ ^#{Puppet::Network::HTTP::MASTER_URL_PREFIX}\/v3\/report\/([^\/]+)$", :method => :save, :allow => '$1', :authenticated => true } @authconfig.stubs(:mk_acl) @authconfig.expects(:mk_acl).with(acl) @authconfig.insert_default_acl end end describe "when checking authorization" do it "should ask for authorization to the ACL subsystem" do params = { :ip => "127.0.0.1", :node => "me", :environment => :env, :authenticated => true } Puppet::Network::Rights.any_instance.expects(:is_request_forbidden_and_why?).with(:save, "/path/to/resource", params) described_class.new.check_authorization(:save, "/path/to/resource", params) end end end diff --git a/spec/unit/network/http/api/ca/v1_spec.rb b/spec/unit/network/http/api/ca/v1_spec.rb new file mode 100644 index 000000000..bb6cff664 --- /dev/null +++ b/spec/unit/network/http/api/ca/v1_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +require 'puppet/network/http' + +describe Puppet::Network::HTTP::API::CA::V1 do + let(:response) { Puppet::Network::HTTP::MemoryResponse.new } + let(:ca_url_prefix) { "#{Puppet::Network::HTTP::CA_URL_PREFIX}/v1"} + + let(:ca_routes) { + Puppet::Network::HTTP::Route. + path(Regexp.new("#{Puppet::Network::HTTP::CA_URL_PREFIX}/")). + any. + chain(Puppet::Network::HTTP::API::CA::V1.routes) + } + + it "mounts ca routes" do + Puppet::SSL::Certificate.indirection.stubs(:find).returns "foo" + request = Puppet::Network::HTTP::Request. + from_hash(:path => "#{ca_url_prefix}/certificate/foo", + :params => {:environment => "production"}, + :headers => {"accept" => "s"}) + ca_routes.process(request, response) + + expect(response.code).to eq(200) + end +end diff --git a/spec/unit/network/http/api/v3/indirected_routes_spec.rb b/spec/unit/network/http/api/indirected_routes_spec.rb similarity index 79% rename from spec/unit/network/http/api/v3/indirected_routes_spec.rb rename to spec/unit/network/http/api/indirected_routes_spec.rb index 3888cb6e3..2a0e84641 100644 --- a/spec/unit/network/http/api/v3/indirected_routes_spec.rb +++ b/spec/unit/network/http/api/indirected_routes_spec.rb @@ -1,493 +1,516 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/network/http' -require 'puppet/network/http/api/v3/indirected_routes' +require 'puppet/network/http/api/indirected_routes' require 'puppet/indirector_testing' -describe Puppet::Network::HTTP::API::V3::IndirectedRoutes do +describe Puppet::Network::HTTP::API::IndirectedRoutes do let(:not_found_code) { Puppet::Network::HTTP::Error::HTTPNotFoundError::CODE } let(:not_acceptable_code) { Puppet::Network::HTTP::Error::HTTPNotAcceptableError::CODE } let(:bad_request_code) { Puppet::Network::HTTP::Error::HTTPBadRequestError::CODE } let(:not_authorized_code) { Puppet::Network::HTTP::Error::HTTPNotAuthorizedError::CODE } let(:indirection) { Puppet::IndirectorTesting.indirection } - let(:handler) { Puppet::Network::HTTP::API::V3::IndirectedRoutes.new } + let(:handler) { Puppet::Network::HTTP::API::IndirectedRoutes.new } let(:response) { Puppet::Network::HTTP::MemoryResponse.new } let(:params) { { :environment => "production" } } - let(:url_prefix) { "#{Puppet[:master_url_prefix]}/v3"} + let(:master_url_prefix) { "#{Puppet::Network::HTTP::MASTER_URL_PREFIX}/v3"} + let(:ca_url_prefix) { "#{Puppet::Network::HTTP::CA_URL_PREFIX}/v1"} def a_request_that_heads(data, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => "text/pson", }, :method => "HEAD", - :path => "#{url_prefix}/#{indirection.name}/#{data.value}", + :path => "#{master_url_prefix}/#{indirection.name}/#{data.value}", :params => params, }) end def a_request_that_submits(data, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => request[:content_type_header] || "text/pson", }, :method => "PUT", - :path => "#{url_prefix}/#{indirection.name}/#{data.value}", + :path => "#{master_url_prefix}/#{indirection.name}/#{data.value}", :params => params, :body => request[:body].nil? ? data.render("pson") : request[:body] }) end def a_request_that_destroys(data, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => "text/pson", }, :method => "DELETE", - :path => "#{url_prefix}/#{indirection.name}/#{data.value}", + :path => "#{master_url_prefix}/#{indirection.name}/#{data.value}", :params => params, :body => '' }) end def a_request_that_finds(data, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => "text/pson", }, :method => "GET", - :path => "#{url_prefix}/#{indirection.name}/#{data.value}", + :path => "#{master_url_prefix}/#{indirection.name}/#{data.value}", :params => params, :body => '' }) end def a_request_that_searches(key, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => "text/pson", }, :method => "GET", - :path => "#{url_prefix}/#{indirection.name}s/#{key}", + :path => "#{master_url_prefix}/#{indirection.name}s/#{key}", :params => params, :body => '' }) end before do Puppet::IndirectorTesting.indirection.terminus_class = :memory Puppet::IndirectorTesting.indirection.terminus.clear handler.stubs(:check_authorization) handler.stubs(:warn_if_near_expiration) end describe "when converting a URI into a request" do let(:environment) { Puppet::Node::Environment.create(:env, []) } let(:env_loaders) { Puppet::Environments::Static.new(environment) } let(:params) { { :environment => "env" } } before do handler.stubs(:handler).returns "foo" end around do |example| Puppet.override(:environments => env_loaders) do example.run end end it "should get the environment from a query parameter" do - handler.uri2indirection("GET", "#{url_prefix}/node/bar", params)[3][:environment].to_s.should == "env" + handler.uri2indirection("GET", "#{master_url_prefix}/node/bar", params)[3][:environment].to_s.should == "env" end it "should fail if there is no environment specified" do - lambda { handler.uri2indirection("GET", "#{url_prefix}/node/bar", {}) }.should raise_error(ArgumentError) + lambda { handler.uri2indirection("GET", "#{master_url_prefix}/node/bar", {}) }.should raise_error(ArgumentError) end it "should fail if the environment is not alphanumeric" do - lambda { handler.uri2indirection("GET", "#{url_prefix}/node/bar", {:environment => "env ness"}) }.should raise_error(ArgumentError) + lambda { handler.uri2indirection("GET", "#{master_url_prefix}/node/bar", {:environment => "env ness"}) }.should raise_error(ArgumentError) + end + + it "should fail if the indirection does not match the prefix" do + lambda { handler.uri2indirection("GET", "#{master_url_prefix}/certificate/foo", params) }.should raise_error(ArgumentError) + end + + it "should fail if the indirection does not have the correct version" do + lambda { handler.uri2indirection("GET", "#{Puppet::Network::HTTP::CA_URL_PREFIX}/v3/certificate/foo", params) }.should raise_error(ArgumentError) end it "should not pass a buck_path parameter through (See Bugs #13553, #13518, #13511)" do - handler.uri2indirection("GET", "#{url_prefix}/node/bar", + handler.uri2indirection("GET", "#{master_url_prefix}/node/bar", { :environment => "env", :bucket_path => "/malicious/path" })[3].should_not include({ :bucket_path => "/malicious/path" }) end it "should pass allowed parameters through" do - handler.uri2indirection("GET", "#{url_prefix}/node/bar", + handler.uri2indirection("GET", "#{master_url_prefix}/node/bar", { :environment => "env", :allowed_param => "value" })[3].should include({ :allowed_param => "value" }) end it "should return the environment as a Puppet::Node::Environment" do - handler.uri2indirection("GET", "#{url_prefix}/node/bar", params)[3][:environment].should be_a(Puppet::Node::Environment) + handler.uri2indirection("GET", "#{master_url_prefix}/node/bar", params)[3][:environment].should be_a(Puppet::Node::Environment) end it "should use the first field of the URI as the indirection name" do - handler.uri2indirection("GET", "#{url_prefix}/node/bar", params)[0].name.should == :node + handler.uri2indirection("GET", "#{master_url_prefix}/node/bar", params)[0].name.should == :node end it "should fail if the indirection name is not alphanumeric" do - lambda { handler.uri2indirection("GET", "#{url_prefix}/foo ness/bar", params) }.should raise_error(ArgumentError) + lambda { handler.uri2indirection("GET", "#{master_url_prefix}/foo ness/bar", params) }.should raise_error(ArgumentError) end it "should use the remainder of the URI as the indirection key" do - handler.uri2indirection("GET", "#{url_prefix}/node/bar", params)[2].should == "bar" + handler.uri2indirection("GET", "#{master_url_prefix}/node/bar", params)[2].should == "bar" end it "should support the indirection key being a /-separated file path" do - handler.uri2indirection("GET", "#{url_prefix}/node/bee/baz/bomb", params)[2].should == "bee/baz/bomb" + handler.uri2indirection("GET", "#{master_url_prefix}/node/bee/baz/bomb", params)[2].should == "bee/baz/bomb" end it "should fail if no indirection key is specified" do - lambda { handler.uri2indirection("GET", "#{url_prefix}/node", params) }.should raise_error(ArgumentError) + lambda { handler.uri2indirection("GET", "#{master_url_prefix}/node", params) }.should raise_error(ArgumentError) end it "should choose 'find' as the indirection method if the http method is a GET and the indirection name is singular" do - handler.uri2indirection("GET", "#{url_prefix}/node/bar", params)[1].should == :find + handler.uri2indirection("GET", "#{master_url_prefix}/node/bar", params)[1].should == :find end it "should choose 'find' as the indirection method if the http method is a POST and the indirection name is singular" do - handler.uri2indirection("POST", "#{url_prefix}/node/bar", params)[1].should == :find + handler.uri2indirection("POST", "#{master_url_prefix}/node/bar", params)[1].should == :find end it "should choose 'head' as the indirection method if the http method is a HEAD and the indirection name is singular" do - handler.uri2indirection("HEAD", "#{url_prefix}/node/bar", params)[1].should == :head + handler.uri2indirection("HEAD", "#{master_url_prefix}/node/bar", params)[1].should == :head end it "should choose 'search' as the indirection method if the http method is a GET and the indirection name is plural" do - handler.uri2indirection("GET", "#{url_prefix}/nodes/bar", params)[1].should == :search + handler.uri2indirection("GET", "#{master_url_prefix}/nodes/bar", params)[1].should == :search end it "should change indirection name to 'status' if the http method is a GET and the indirection name is statuses" do - handler.uri2indirection("GET", "#{url_prefix}/statuses/bar", params)[0].name.should == :status + handler.uri2indirection("GET", "#{master_url_prefix}/statuses/bar", params)[0].name.should == :status end it "should change indirection name to 'node' if the http method is a GET and the indirection name is nodes" do - handler.uri2indirection("GET", "#{url_prefix}/nodes/bar", params)[0].name.should == :node + handler.uri2indirection("GET", "#{master_url_prefix}/nodes/bar", params)[0].name.should == :node end it "should choose 'delete' as the indirection method if the http method is a DELETE and the indirection name is singular" do - handler.uri2indirection("DELETE", "#{url_prefix}/node/bar", params)[1].should == :destroy + handler.uri2indirection("DELETE", "#{master_url_prefix}/node/bar", params)[1].should == :destroy end it "should choose 'save' as the indirection method if the http method is a PUT and the indirection name is singular" do - handler.uri2indirection("PUT", "#{url_prefix}/node/bar", params)[1].should == :save + handler.uri2indirection("PUT", "#{master_url_prefix}/node/bar", params)[1].should == :save end it "should fail if an indirection method cannot be picked" do - lambda { handler.uri2indirection("UPDATE", "#{url_prefix}/node/bar", params) }.should raise_error(ArgumentError) + lambda { handler.uri2indirection("UPDATE", "#{master_url_prefix}/node/bar", params) }.should raise_error(ArgumentError) end it "should URI unescape the indirection key" do escaped = URI.escape("foo bar") - indirection, method, key, final_params = handler.uri2indirection("GET", "#{url_prefix}/node/#{escaped}", params) + indirection, method, key, final_params = handler.uri2indirection("GET", "#{master_url_prefix}/node/#{escaped}", params) key.should == "foo bar" end end describe "when converting a request into a URI" do let(:environment) { Puppet::Node::Environment.create(:myenv, []) } let(:request) { Puppet::Indirector::Request.new(:foo, :find, "with spaces", nil, :foo => :bar, :environment => environment) } before do handler.stubs(:handler).returns "foo" end it "should include the environment in the query string of the URI" do - handler.class.request_to_uri(request).should == "#{url_prefix}/foo/with%20spaces?environment=myenv&foo=bar" + handler.class.request_to_uri(request).should == "#{master_url_prefix}/foo/with%20spaces?environment=myenv&foo=bar" + end + + it "should include the correct url prefix if it is a ca request" do + request.stubs(:indirection_name).returns("certificate") + handler.class.request_to_uri(request).should == "#{ca_url_prefix}/certificate/with%20spaces?environment=myenv&foo=bar" end it "should pluralize the indirection name if the method is 'search'" do request.stubs(:method).returns :search handler.class.request_to_uri(request).split("/")[3].should == "foos" end it "should add the query string to the URI" do request.expects(:query_string).returns "query" handler.class.request_to_uri(request).should =~ /\&query$/ end end describe "when converting a request into a URI with body" do let(:environment) { Puppet::Node::Environment.create(:myenv, []) } let(:request) { Puppet::Indirector::Request.new(:foo, :find, "with spaces", nil, :foo => :bar, :environment => environment) } it "should use the indirection as the first field of the URI" do handler.class.request_to_uri_and_body(request).first.split("/")[3].should == "foo" end it "should use the escaped key as the remainder of the URI" do escaped = URI.escape("with spaces") handler.class.request_to_uri_and_body(request).first.split("/")[4].sub(/\?.+/, '').should == escaped end + it "should include the correct url prefix if it is a master request" do + handler.class.request_to_uri_and_body(request).first.should == "#{master_url_prefix}/foo/with%20spaces" + end + + it "should include the correct url prefix if it is a ca request" do + request.stubs(:indirection_name).returns("certificate") + handler.class.request_to_uri_and_body(request).first.should == "#{ca_url_prefix}/certificate/with%20spaces" + end + it "should return the URI and body separately" do - handler.class.request_to_uri_and_body(request).should == ["#{url_prefix}/foo/with%20spaces", "environment=myenv&foo=bar"] + handler.class.request_to_uri_and_body(request).should == ["#{master_url_prefix}/foo/with%20spaces", "environment=myenv&foo=bar"] end end describe "when processing a request" do it "should return not_authorized_code if the request is not authorized" do request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) handler.expects(:check_authorization).raises(Puppet::Network::AuthorizationError.new("forbidden")) handler.call(request, response) expect(response.code).to eq(not_authorized_code) end it "should return 'not found' if the indirection does not support remote requests" do request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) indirection.expects(:allow_remote_requests?).returns(false) handler.call(request, response) expect(response.code).to eq(not_found_code) end it "should return 'bad request' if the environment does not exist" do Puppet.override(:environments => Puppet::Environments::Static.new()) do request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) handler.call(request, response) expect(response.code).to eq(bad_request_code) end end it "should serialize a controller exception when an exception is thrown while finding the model instance" do request = a_request_that_finds(Puppet::IndirectorTesting.new("key")) handler.expects(:do_find).raises(ArgumentError, "The exception") handler.call(request, response) expect(response.code).to eq(bad_request_code) expect(response.body).to eq("The exception") expect(response.type).to eq("text/plain") end end describe "when finding a model instance" do it "uses the first supported format for the response" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_finds(data, :accept_header => "unknown, pson") handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "responds with a not_acceptable_code error when no accept header is provided" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_finds(data, :accept_header => nil) handler.call(request, response) expect(response.code).to eq(not_acceptable_code) end it "raises an error when no accepted formats are known" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_finds(data, :accept_header => "unknown, also/unknown") handler.call(request, response) expect(response.code).to eq(not_acceptable_code) end it "should pass the result through without rendering it if the result is a string" do data = Puppet::IndirectorTesting.new("my data") data_string = "my data string" request = a_request_that_finds(data, :accept_header => "text/pson") indirection.expects(:find).returns(data_string) handler.call(request, response) expect(response.body).to eq(data_string) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "should return a not_found_code when no model instance can be found" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_finds(data, :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.code).to eq(not_found_code) end end describe "when searching for model instances" do it "uses the first supported format for the response" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_searches("my", :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) expect(response.body).to eq(Puppet::IndirectorTesting.render_multiple(:pson, [data])) end it "should return [] when searching returns an empty array" do request = a_request_that_searches("nothing", :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.body).to eq("[]") expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "should return a not_found_code when searching returns nil" do request = a_request_that_searches("nothing", :accept_header => "unknown, text/pson") indirection.expects(:search).returns(nil) handler.call(request, response) expect(response.code).to eq(not_found_code) end end describe "when destroying a model instance" do it "destroys the data indicated in the request" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_destroys(data) handler.call(request, response) Puppet::IndirectorTesting.indirection.find("my data").should be_nil end it "responds with pson when no Accept header is given" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_destroys(data, :accept_header => nil) handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "uses the first supported format for the response" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_destroys(data, :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "raises an error and does not destroy when no accepted formats are known" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_destroys(data, :accept_header => "unknown, also/unknown") handler.call(request, response) expect(response.code).to eq(not_acceptable_code) Puppet::IndirectorTesting.indirection.find("my data").should_not be_nil end end describe "when saving a model instance" do it "allows an empty body when the format supports it" do class Puppet::IndirectorTesting::Nonvalidatingmemory < Puppet::IndirectorTesting::Memory def validate_key(_) # nothing end end indirection.terminus_class = :nonvalidatingmemory data = Puppet::IndirectorTesting.new("test") request = a_request_that_submits(data, :content_type_header => "application/x-raw", :body => '') handler.call(request, response) # PUP-3272 this test fails when yaml is removed and pson is used. Instead of returning an # empty string, the a string '""' is returned - Don't know what the expecation is, if this is # corrent or not. # (helindbe) # Puppet::IndirectorTesting.indirection.find("test").name.should == '' end it "saves the data sent in the request" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_submits(data) handler.call(request, response) saved = Puppet::IndirectorTesting.indirection.find("my data") expect(saved.name).to eq(data.name) end it "responds with pson when no Accept header is given" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_submits(data, :accept_header => nil) handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "uses the first supported format for the response" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_submits(data, :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "raises an error and does not save when no accepted formats are known" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_submits(data, :accept_header => "unknown, also/unknown") handler.call(request, response) expect(Puppet::IndirectorTesting.indirection.find("my data")).to be_nil expect(response.code).to eq(not_acceptable_code) end end describe "when performing head operation" do it "should not generate a response when a model head call succeeds" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_heads(data) handler.call(request, response) expect(response.code).to eq(nil) end it "should return a not_found_code when the model head call returns false" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_heads(data) handler.call(request, response) expect(response.code).to eq(not_found_code) expect(response.type).to eq("text/plain") expect(response.body).to eq("Not Found: Could not find indirector_testing my data") end end end diff --git a/spec/unit/network/http/api/v2/authorization_spec.rb b/spec/unit/network/http/api/master/v2/authorization_spec.rb similarity index 91% rename from spec/unit/network/http/api/v2/authorization_spec.rb rename to spec/unit/network/http/api/master/v2/authorization_spec.rb index ecdb76192..f4bff9c4e 100644 --- a/spec/unit/network/http/api/v2/authorization_spec.rb +++ b/spec/unit/network/http/api/master/v2/authorization_spec.rb @@ -1,57 +1,57 @@ require 'spec_helper' require 'puppet/network/http' -describe Puppet::Network::HTTP::API::V2::Authorization do +describe Puppet::Network::HTTP::API::Master::V2::Authorization do HTTP = Puppet::Network::HTTP let(:response) { HTTP::MemoryResponse.new } - let(:authz) { HTTP::API::V2::Authorization.new } + let(:authz) { HTTP::API::Master::V2::Authorization.new } it "only authorizes GET requests" do request = HTTP::Request.from_hash({ :method => "POST" }) expect do authz.call(request, response) end.to raise_error(HTTP::Error::HTTPNotAuthorizedError) end it "accepts v2 api requests that match allowed authconfig entries" do request = HTTP::Request.from_hash({ :path => "/v2.0/environments", :method => "GET", :params => { :authenticated => true, :node => "testing", :ip => "127.0.0.1" } }) authz.stubs(:authconfig).returns(Puppet::Network::AuthConfigParser.new(<<-AUTH).parse) path /v2.0/environments method find allow * AUTH expect do authz.call(request, response) end.to_not raise_error end it "rejects v2 api requests that are disallowed by authconfig entries" do request = HTTP::Request.from_hash({ :path => "/v2.0/environments", :method => "GET", :params => { :node => "testing", :ip => "127.0.0.1" } }) authz.stubs(:authconfig).returns(Puppet::Network::AuthConfigParser.new(<<-AUTH).parse) path /v2.0/environments method find auth any deny testing AUTH expect do authz.call(request, response) end.to raise_error(HTTP::Error::HTTPNotAuthorizedError, /Forbidden request/) end end diff --git a/spec/unit/network/http/api/v2/environments_spec.rb b/spec/unit/network/http/api/master/v2/environments_spec.rb similarity index 88% rename from spec/unit/network/http/api/v2/environments_spec.rb rename to spec/unit/network/http/api/master/v2/environments_spec.rb index 34e4359eb..3283b801f 100644 --- a/spec/unit/network/http/api/v2/environments_spec.rb +++ b/spec/unit/network/http/api/master/v2/environments_spec.rb @@ -1,63 +1,63 @@ require 'spec_helper' require 'puppet/node/environment' require 'puppet/network/http' require 'matchers/json' -describe Puppet::Network::HTTP::API::V2::Environments do +describe Puppet::Network::HTTP::API::Master::V2::Environments do include JSONMatchers it "responds with all of the available environments" do environment = Puppet::Node::Environment.create(:production, ["/first", "/second"], '/manifests') loader = Puppet::Environments::Static.new(environment) - handler = Puppet::Network::HTTP::API::V2::Environments.new(loader) + handler = Puppet::Network::HTTP::API::Master::V2::Environments.new(loader) response = Puppet::Network::HTTP::MemoryResponse.new handler.call(Puppet::Network::HTTP::Request.from_hash(:headers => { 'accept' => 'application/json' }), response) expect(response.code).to eq(200) expect(response.type).to eq("application/json") expect(JSON.parse(response.body)).to eq({ "search_paths" => loader.search_paths, "environments" => { "production" => { "settings" => { "modulepath" => [File.expand_path("/first"), File.expand_path("/second")], "manifest" => File.expand_path("/manifests"), "environment_timeout" => 0, "config_version" => "" } } } }) end it "the response conforms to the environments schema for unlimited timeout" do conf_stub = stub 'conf_stub' conf_stub.expects(:environment_timeout).returns(Float::INFINITY) environment = Puppet::Node::Environment.create(:production, []) env_loader = Puppet::Environments::Static.new(environment) env_loader.expects(:get_conf).with(:production).returns(conf_stub) - handler = Puppet::Network::HTTP::API::V2::Environments.new(env_loader) + handler = Puppet::Network::HTTP::API::Master::V2::Environments.new(env_loader) response = Puppet::Network::HTTP::MemoryResponse.new handler.call(Puppet::Network::HTTP::Request.from_hash(:headers => { 'accept' => 'application/json' }), response) expect(response.body).to validate_against('api/schemas/environments.json') end it "the response conforms to the environments schema for integer timeout" do conf_stub = stub 'conf_stub' conf_stub.expects(:environment_timeout).returns(1) environment = Puppet::Node::Environment.create(:production, []) env_loader = Puppet::Environments::Static.new(environment) env_loader.expects(:get_conf).with(:production).returns(conf_stub) - handler = Puppet::Network::HTTP::API::V2::Environments.new(env_loader) + handler = Puppet::Network::HTTP::API::Master::V2::Environments.new(env_loader) response = Puppet::Network::HTTP::MemoryResponse.new handler.call(Puppet::Network::HTTP::Request.from_hash(:headers => { 'accept' => 'application/json' }), response) expect(response.body).to validate_against('api/schemas/environments.json') end end diff --git a/spec/unit/network/http/api/v2_spec.rb b/spec/unit/network/http/api/master/v2_spec.rb similarity index 57% rename from spec/unit/network/http/api/v2_spec.rb rename to spec/unit/network/http/api/master/v2_spec.rb index c4fd9dcc6..219276163 100644 --- a/spec/unit/network/http/api/v2_spec.rb +++ b/spec/unit/network/http/api/master/v2_spec.rb @@ -1,22 +1,25 @@ require 'spec_helper' require 'puppet/network/http' -describe Puppet::Network::HTTP::API::V2 do +describe Puppet::Network::HTTP::API::Master::V2 do let(:response) { Puppet::Network::HTTP::MemoryResponse.new } + let(:routes) { Puppet::Network::HTTP::Route.path(Regexp.new("/puppet/")). + any. + chain(Puppet::Network::HTTP::API::Master::V2.routes) } it "mounts the environments endpoint" do - request = Puppet::Network::HTTP::Request.from_hash(:path => "/v2.0/environments") - Puppet::Network::HTTP::API::V2.routes.process(request, response) + request = Puppet::Network::HTTP::Request.from_hash(:path => "/puppet/v2.0/environments") + routes.process(request, response) expect(response.code).to eq(200) end it "responds to unknown paths with a 404" do - request = Puppet::Network::HTTP::Request.from_hash(:path => "/v2.0/unknown") + request = Puppet::Network::HTTP::Request.from_hash(:path => "/puppet/v2.0/unknown") expect do - Puppet::Network::HTTP::API::V2.routes.process(request, response) + routes.process(request, response) end.to raise_error(Puppet::Network::HTTP::Error::HTTPNotFoundError) end end diff --git a/spec/unit/network/http/api/v3/authorization_spec.rb b/spec/unit/network/http/api/master/v3/authorization_spec.rb similarity index 91% rename from spec/unit/network/http/api/v3/authorization_spec.rb rename to spec/unit/network/http/api/master/v3/authorization_spec.rb index 2cbdc965b..813ff302f 100644 --- a/spec/unit/network/http/api/v3/authorization_spec.rb +++ b/spec/unit/network/http/api/master/v3/authorization_spec.rb @@ -1,59 +1,59 @@ require 'spec_helper' require 'puppet/network/http' -describe Puppet::Network::HTTP::API::V3::Authorization do +describe Puppet::Network::HTTP::API::Master::V3::Authorization do HTTP = Puppet::Network::HTTP let(:response) { HTTP::MemoryResponse.new } - let(:authz) { HTTP::API::V3::Authorization.new } + let(:authz) { HTTP::API::Master::V3::Authorization.new } let(:noop_handler) { lambda do |request, response| end } it "accepts v3 api requests that match allowed authconfig entries" do request = HTTP::Request.from_hash({ :path => "/v3/environments", :method => "GET", :params => { :authenticated => true, :node => "testing", :ip => "127.0.0.1" } }) authz.stubs(:authconfig).returns(Puppet::Network::AuthConfigParser.new(<<-AUTH).parse) path /v3/environments method find allow * AUTH handler = authz.wrap do noop_handler end expect do handler.call(request, response) end.to_not raise_error end it "rejects v3 api requests that are disallowed by authconfig entries" do request = HTTP::Request.from_hash({ :path => "/v3/environments", :method => "GET", :params => { :authenticated => true, :node => "testing", :ip => "127.0.0.1" } }) authz.stubs(:authconfig).returns(Puppet::Network::AuthConfigParser.new(<<-AUTH).parse) path /v3/environments method find auth any deny testing AUTH handler = authz.wrap do noop_handler end expect do handler.call(request, response) end.to raise_error(HTTP::Error::HTTPNotAuthorizedError, /Forbidden request/) end end diff --git a/spec/unit/network/http/api/v3/environments_spec.rb b/spec/unit/network/http/api/master/v3/environments_spec.rb similarity index 88% rename from spec/unit/network/http/api/v3/environments_spec.rb rename to spec/unit/network/http/api/master/v3/environments_spec.rb index 6d5e4a102..2606428da 100644 --- a/spec/unit/network/http/api/v3/environments_spec.rb +++ b/spec/unit/network/http/api/master/v3/environments_spec.rb @@ -1,63 +1,63 @@ require 'spec_helper' require 'puppet/node/environment' require 'puppet/network/http' require 'matchers/json' -describe Puppet::Network::HTTP::API::V3::Environments do +describe Puppet::Network::HTTP::API::Master::V3::Environments do include JSONMatchers it "responds with all of the available environments" do environment = Puppet::Node::Environment.create(:production, ["/first", "/second"], '/manifests') loader = Puppet::Environments::Static.new(environment) - handler = Puppet::Network::HTTP::API::V3::Environments.new(loader) + handler = Puppet::Network::HTTP::API::Master::V3::Environments.new(loader) response = Puppet::Network::HTTP::MemoryResponse.new handler.call(Puppet::Network::HTTP::Request.from_hash(:headers => { 'accept' => 'application/json' }), response) expect(response.code).to eq(200) expect(response.type).to eq("application/json") expect(JSON.parse(response.body)).to eq({ "search_paths" => loader.search_paths, "environments" => { "production" => { "settings" => { "modulepath" => [File.expand_path("/first"), File.expand_path("/second")], "manifest" => File.expand_path("/manifests"), "environment_timeout" => 0, "config_version" => "" } } } }) end it "the response conforms to the environments schema for unlimited timeout" do conf_stub = stub 'conf_stub' conf_stub.expects(:environment_timeout).returns(Float::INFINITY) environment = Puppet::Node::Environment.create(:production, []) env_loader = Puppet::Environments::Static.new(environment) env_loader.expects(:get_conf).with(:production).returns(conf_stub) - handler = Puppet::Network::HTTP::API::V3::Environments.new(env_loader) + handler = Puppet::Network::HTTP::API::Master::V3::Environments.new(env_loader) response = Puppet::Network::HTTP::MemoryResponse.new handler.call(Puppet::Network::HTTP::Request.from_hash(:headers => { 'accept' => 'application/json' }), response) expect(response.body).to validate_against('api/schemas/environments.json') end it "the response conforms to the environments schema for integer timeout" do conf_stub = stub 'conf_stub' conf_stub.expects(:environment_timeout).returns(1) environment = Puppet::Node::Environment.create(:production, []) env_loader = Puppet::Environments::Static.new(environment) env_loader.expects(:get_conf).with(:production).returns(conf_stub) - handler = Puppet::Network::HTTP::API::V3::Environments.new(env_loader) + handler = Puppet::Network::HTTP::API::Master::V3::Environments.new(env_loader) response = Puppet::Network::HTTP::MemoryResponse.new handler.call(Puppet::Network::HTTP::Request.from_hash(:headers => { 'accept' => 'application/json' }), response) expect(response.body).to validate_against('api/schemas/environments.json') end end diff --git a/spec/unit/network/http/api/v3_spec.rb b/spec/unit/network/http/api/master/v3_spec.rb similarity index 59% rename from spec/unit/network/http/api/v3_spec.rb rename to spec/unit/network/http/api/master/v3_spec.rb index 5d3ecf5de..e249cd731 100755 --- a/spec/unit/network/http/api/v3_spec.rb +++ b/spec/unit/network/http/api/master/v3_spec.rb @@ -1,39 +1,39 @@ require 'spec_helper' require 'puppet/network/http' -describe Puppet::Network::HTTP::API::V3 do +describe Puppet::Network::HTTP::API::Master::V3 do let(:response) { Puppet::Network::HTTP::MemoryResponse.new } - let(:url_prefix) { "#{Puppet[:master_url_prefix]}/v3"} - let(:routes) { + let(:master_url_prefix) { "#{Puppet::Network::HTTP::MASTER_URL_PREFIX}/v3" } + let(:master_routes) { Puppet::Network::HTTP::Route. - path(Regexp.new(Puppet[:master_url_prefix])). + path(Regexp.new("#{Puppet::Network::HTTP::MASTER_URL_PREFIX}/")). any. - chain(Puppet::Network::HTTP::API::V3.routes) + chain(Puppet::Network::HTTP::API::Master::V3.routes) } it "mounts the environments endpoint" do - request = Puppet::Network::HTTP::Request.from_hash(:path => "#{url_prefix}/environments") - routes.process(request, response) + request = Puppet::Network::HTTP::Request.from_hash(:path => "#{master_url_prefix}/environments") + master_routes.process(request, response) expect(response.code).to eq(200) end it "mounts indirected routes" do request = Puppet::Network::HTTP::Request. - from_hash(:path => "#{url_prefix}/node/foo", + from_hash(:path => "#{master_url_prefix}/node/foo", :params => {:environment => "production"}, :headers => {"accept" => "text/pson"}) - routes.process(request, response) + master_routes.process(request, response) expect(response.code).to eq(200) end it "responds to unknown paths with a 404" do - request = Puppet::Network::HTTP::Request.from_hash(:path => "#{url_prefix}/unknown") - routes.process(request, response) + request = Puppet::Network::HTTP::Request.from_hash(:path => "#{master_url_prefix}/unknown") + master_routes.process(request, response) expect(response.code).to eq(404) expect(response.body).to match("Not Found: Could not find indirection 'unknown'") end end diff --git a/spec/unit/network/http/api_spec.rb b/spec/unit/network/http/api_spec.rb index 1971f9265..d9e6e458c 100644 --- a/spec/unit/network/http/api_spec.rb +++ b/spec/unit/network/http/api_spec.rb @@ -1,37 +1,105 @@ #! /usr/bin/env ruby require 'spec_helper' +require 'puppet_spec/handler' require 'puppet/network/http' describe Puppet::Network::HTTP::API do def respond(text) lambda { |req, res| res.respond_with(200, "text/plain", text) } end - let(:response) { Puppet::Network::HTTP::MemoryResponse.new } + describe "#not_found" do + let(:response) { Puppet::Network::HTTP::MemoryResponse.new } - let(:routes) { - Puppet::Network::HTTP::Route.path(Regexp.new("foo")). - any. - chain(Puppet::Network::HTTP::Route.path(%r{^/bar$}).get(respond("bar")), - Puppet::Network::HTTP::API.not_found) - } + let(:routes) { + Puppet::Network::HTTP::Route.path(Regexp.new("foo")). + any. + chain(Puppet::Network::HTTP::Route.path(%r{^/bar$}).get(respond("bar")), + Puppet::Network::HTTP::API.not_found) + } - it "mounts the bar endpoint" do - request = Puppet::Network::HTTP::Request.from_hash(:path => "foo/bar") - routes.process(request, response) + it "mounts the chained routes" do + request = Puppet::Network::HTTP::Request.from_hash(:path => "foo/bar") + routes.process(request, response) - expect(response.code).to eq(200) - expect(response.body).to eq("bar") - end + expect(response.code).to eq(200) + expect(response.body).to eq("bar") + end - it "responds to unknown paths with a 404" do - request = Puppet::Network::HTTP::Request.from_hash(:path => "foo/unknown") + it "responds to unknown paths with a 404" do + request = Puppet::Network::HTTP::Request.from_hash(:path => "foo/unknown") - expect do - routes.process(request, response) - end.to raise_error(Puppet::Network::HTTP::Error::HTTPNotFoundError) + expect do + routes.process(request, response) + end.to raise_error(Puppet::Network::HTTP::Error::HTTPNotFoundError) + end end -end + describe "Puppet API" do + let(:handler) { PuppetSpec::Handler.new(Puppet::Network::HTTP::API.master_routes, + Puppet::Network::HTTP::API.ca_routes) } + + let(:master_prefix) { Puppet::Network::HTTP::MASTER_URL_PREFIX } + let(:ca_prefix) { Puppet::Network::HTTP::CA_URL_PREFIX } + + it "raises a not-found error for non-CA or master routes" do + req = Puppet::Network::HTTP::Request.from_hash(:path => "/unknown") + res = {} + handler.process(req, res) + expect(res[:status]).to eq(404) + end + + describe "when processing master routes" do + it "responds to v3 indirector requests" do + req = Puppet::Network::HTTP::Request.from_hash(:path => "#{master_prefix}/v3/node/foo", + :params => {:environment => "production"}, + :headers => {'accept' => "pson"}) + res = {} + handler.process(req, res) + expect(res[:status]).to eq(200) + end + it "responds to v3 environments requests" do + req = Puppet::Network::HTTP::Request.from_hash(:path => "#{master_prefix}/v3/environments") + res = {} + handler.process(req, res) + expect(res[:status]).to eq(200) + end + + it "responds to v2.0 environments requests" do + req = Puppet::Network::HTTP::Request.from_hash(:path => "#{master_prefix}/v2.0/environments") + res = {} + handler.process(req, res) + expect(res[:status]).to eq(200) + end + + it "responds with a not found error to non-v3/2.0 requests" do + req = Puppet::Network::HTTP::Request.from_hash(:path => "#{master_prefix}/unknown") + res = {} + handler.process(req, res) + expect(res[:status]).to eq(404) + end + end + + describe "when processing CA routes" do + it "responds to v1 indirector requests" do + Puppet::SSL::Certificate.indirection.stubs(:find).returns "foo" + req = Puppet::Network::HTTP::Request.from_hash(:path => "#{ca_prefix}/v1/certificate/foo", + :params => {:environment => "production"}, + :headers => {'accept' => "s"}) + res = {} + handler.process(req, res) + expect(res[:body]).to eq("foo") + expect(res[:status]).to eq(200) + end + + it "responds with a not found error to non-v1 requests" do + req = Puppet::Network::HTTP::Request.from_hash(:path => "#{ca_prefix}/unknown") + res = {} + handler.process(req, res) + expect(res[:status]).to eq(404) + end + end + end +end diff --git a/spec/unit/network/http/handler_spec.rb b/spec/unit/network/http/handler_spec.rb index 9b0b754e5..17c9e8958 100755 --- a/spec/unit/network/http/handler_spec.rb +++ b/spec/unit/network/http/handler_spec.rb @@ -1,228 +1,179 @@ #! /usr/bin/env ruby require 'spec_helper' + +require 'puppet_spec/handler' + require 'puppet/indirector_testing' require 'puppet/network/authorization' require 'puppet/network/http' describe Puppet::Network::HTTP::Handler do before :each do Puppet::IndirectorTesting.indirection.terminus_class = :memory end let(:indirection) { Puppet::IndirectorTesting.indirection } def a_request(method = "HEAD", path = "/production/#{indirection.name}/unknown") { :accept_header => "pson", :content_type_header => "text/pson", - :http_method => method, + :method => method, :path => path, :params => {}, :client_cert => nil, :headers => {}, :body => nil } end - let(:handler) { TestingHandler.new() } + let(:handler) { PuppetSpec::Handler.new() } describe "the HTTP Handler" do def respond(text) lambda { |req, res| res.respond_with(200, "text/plain", text) } end it "hands the request to the first route that matches the request path" do - handler = TestingHandler.new( + handler = PuppetSpec::Handler.new( Puppet::Network::HTTP::Route.path(%r{^/foo}).get(respond("skipped")), Puppet::Network::HTTP::Route.path(%r{^/vtest}).get(respond("used")), Puppet::Network::HTTP::Route.path(%r{^/vtest/foo}).get(respond("ignored"))) req = a_request("GET", "/vtest/foo") res = {} handler.process(req, res) expect(res[:body]).to eq("used") end it "raises an error if multiple routes with the same path regex are registered" do expect do - handler = TestingHandler.new( + handler = PuppetSpec::Handler.new( Puppet::Network::HTTP::Route.path(%r{^/foo}).get(respond("ignored")), Puppet::Network::HTTP::Route.path(%r{^/foo}).post(respond("also ignored"))) end.to raise_error(ArgumentError) end it "raises an HTTP not found error if no routes match" do - handler = TestingHandler.new + handler = PuppetSpec::Handler.new req = a_request("GET", "/vtest/foo") res = {} handler.process(req, res) res_body = JSON(res[:body]) expect(res[:content_type_header]).to eq("application/json") expect(res_body["issue_kind"]).to eq("HANDLER_NOT_FOUND") expect(res_body["message"]).to eq("Not Found: No route for GET /vtest/foo") expect(res[:status]).to eq(404) end it "returns a structured error response with a stacktrace when the server encounters an internal error" do - handler = TestingHandler.new( + handler = PuppetSpec::Handler.new( Puppet::Network::HTTP::Route.path(/.*/).get(lambda { |_, _| raise StandardError.new("the sky is falling!")})) req = a_request("GET", "/vtest/foo") res = {} handler.process(req, res) res_body = JSON(res[:body]) expect(res[:content_type_header]).to eq("application/json") expect(res_body["issue_kind"]).to eq(Puppet::Network::HTTP::Issues::RUNTIME_ERROR.to_s) expect(res_body["message"]).to eq("Server Error: the sky is falling!") expect(res_body["stacktrace"].is_a?(Array) && !res_body["stacktrace"].empty?).to be_true expect(res_body["stacktrace"][0]).to match("spec/unit/network/http/handler_spec.rb") expect(res[:status]).to eq(500) end end describe "when processing a request" do let(:response) do { :status => 200 } end before do handler.stubs(:check_authorization) handler.stubs(:warn_if_near_expiration) end it "should setup a profiler when the puppet-profiling header exists" do request = a_request request[:headers][Puppet::Network::HTTP::HEADER_ENABLE_PROFILING.downcase] = "true" - p = HandlerTestProfiler.new + p = PuppetSpec::HandlerProfiler.new Puppet::Util::Profiler.expects(:add_profiler).with { |profiler| profiler.is_a? Puppet::Util::Profiler::WallClock }.returns(p) Puppet::Util::Profiler.expects(:remove_profiler).with { |profiler| profiler == p } handler.process(request, response) end it "should not setup profiler when the profile parameter is missing" do request = a_request request[:params] = { } Puppet::Util::Profiler.expects(:add_profiler).never handler.process(request, response) end it "should raise an error if the request is formatted in an unknown format" do handler.stubs(:content_type_header).returns "unknown format" lambda { handler.request_format(request) }.should raise_error end it "should still find the correct format if content type contains charset information" do request = Puppet::Network::HTTP::Request.new({ 'content-type' => "text/plain; charset=UTF-8" }, {}, 'GET', '/', nil) request.format.should == "s" end # PUP-3272 # This used to be for YAML, and doing a to_yaml on an array. # The result with to_pson is something different, the result is a string # Which seems correct. Looks like this was some kind of nesting option "yaml inside yaml" ? # Removing the test # it "should deserialize PSON parameters" do # params = {'my_param' => [1,2,3].to_pson} # # decoded_params = handler.send(:decode_params, params) # # decoded_params.should == {:my_param => [1,2,3]} # end end describe "when resolving node" do it "should use a look-up from the ip address" do Resolv.expects(:getname).with("1.2.3.4").returns("host.domain.com") handler.resolve_node(:ip => "1.2.3.4") end it "should return the look-up result" do Resolv.stubs(:getname).with("1.2.3.4").returns("host.domain.com") handler.resolve_node(:ip => "1.2.3.4").should == "host.domain.com" end it "should return the ip address if resolving fails" do Resolv.stubs(:getname).with("1.2.3.4").raises(RuntimeError, "no such host") handler.resolve_node(:ip => "1.2.3.4").should == "1.2.3.4" end end - - class TestingHandler - include Puppet::Network::HTTP::Handler - - def initialize(* routes) - register(routes) - end - - def set_content_type(response, format) - response[:content_type_header] = format - end - - def set_response(response, body, status = 200) - response[:body] = body - response[:status] = status - end - - def http_method(request) - request[:http_method] - end - - def path(request) - request[:path] - end - - def params(request) - request[:params] - end - - def client_cert(request) - request[:client_cert] - end - - def body(request) - request[:body] - end - - def headers(request) - request[:headers] || {} - end - end - - class HandlerTestProfiler - def start(metric, description) - end - - def finish(context, metric, description) - end - - def shutdown() - end - end end diff --git a/spec/unit/type/file/content_spec.rb b/spec/unit/type/file/content_spec.rb index 8147b7c1f..c9570955b 100755 --- a/spec/unit/type/file/content_spec.rb +++ b/spec/unit/type/file/content_spec.rb @@ -1,521 +1,521 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/network/http_pool' require 'puppet/network/resolver' describe Puppet::Type.type(:file).attrclass(:content), :uses_checksums => true do include PuppetSpec::Files let(:filename) { tmpfile('testfile') } let(:environment) { Puppet::Node::Environment.create(:testing, []) } let(:catalog) { Puppet::Resource::Catalog.new(:test, environment) } let(:resource) { Puppet::Type.type(:file).new :path => filename, :catalog => catalog } before do File.open(filename, 'w') {|f| f.write "initial file content"} described_class.stubs(:standalone?).returns(false) end around do |example| Puppet.override(:environments => Puppet::Environments::Static.new(environment)) do example.run end end describe "when determining the checksum type" do let(:content) { described_class.new(:resource => resource) } it "should use the type specified in the source checksum if a source is set" do resource[:source] = File.expand_path("/foo") resource.parameter(:source).expects(:checksum).returns "{md5lite}eh" content.checksum_type.should == :md5lite end it "should use the type specified by the checksum parameter if no source is set" do resource[:checksum] = :md5lite content.checksum_type.should == :md5lite end with_digest_algorithms do it "should use the type specified by digest_algorithm by default" do content.checksum_type.should == digest_algorithm.intern end end end describe "when determining the actual content to write" do let(:content) { described_class.new(:resource => resource) } it "should use the set content if available" do content.should = "ehness" content.actual_content.should == "ehness" end it "should not use the content from the source if the source is set" do source = mock 'source' resource.expects(:parameter).never.with(:source).returns source content.actual_content.should be_nil end end describe "when setting the desired content" do let(:content) { described_class.new(:resource => resource) } it "should make the actual content available via an attribute" do content.stubs(:checksum_type).returns "md5" content.should = "this is some content" content.actual_content.should == "this is some content" end with_digest_algorithms do it "should store the checksum as the desired content" do d = digest("this is some content") content.stubs(:checksum_type).returns digest_algorithm content.should = "this is some content" content.should.must == "{#{digest_algorithm}}#{d}" end it "should not checksum 'absent'" do content.should = :absent content.should.must == :absent end it "should accept a checksum as the desired content" do d = digest("this is some content") string = "{#{digest_algorithm}}#{d}" content.should = string content.should.must == string end end it "should convert the value to ASCII-8BIT", :if => "".respond_to?(:encode) do content.should= "Let's make a \u{2603}" content.actual_content.should == "Let's make a \xE2\x98\x83".force_encoding(Encoding::ASCII_8BIT) end end describe "when retrieving the current content" do let(:content) { described_class.new(:resource => resource) } it "should return :absent if the file does not exist" do resource.expects(:stat).returns nil content.retrieve.should == :absent end it "should not manage content on directories" do stat = mock 'stat', :ftype => "directory" resource.expects(:stat).returns stat content.retrieve.should be_nil end it "should not manage content on links" do stat = mock 'stat', :ftype => "link" resource.expects(:stat).returns stat content.retrieve.should be_nil end it "should always return the checksum as a string" do resource[:checksum] = :mtime stat = mock 'stat', :ftype => "file" resource.expects(:stat).returns stat time = Time.now resource.parameter(:checksum).expects(:mtime_file).with(resource[:path]).returns time content.retrieve.should == "{mtime}#{time}" end with_digest_algorithms do it "should return the checksum of the file if it exists and is a normal file" do stat = mock 'stat', :ftype => "file" resource.expects(:stat).returns stat resource.parameter(:checksum).expects("#{digest_algorithm}_file".intern).with(resource[:path]).returns "mysum" content.retrieve.should == "{#{digest_algorithm}}mysum" end end end describe "when testing whether the content is in sync" do let(:content) { described_class.new(:resource => resource) } before do resource[:ensure] = :file end it "should return true if the resource shouldn't be a regular file" do resource.expects(:should_be_file?).returns false content.should = "foo" content.must be_safe_insync("whatever") end it "should warn that no content will be synced to links when ensure is :present" do resource[:ensure] = :present resource[:content] = 'foo' resource.stubs(:should_be_file?).returns false resource.stubs(:stat).returns mock("stat", :ftype => "link") resource.expects(:warning).with {|msg| msg =~ /Ensure set to :present but file type is/} content.insync? :present end it "should return false if the current content is :absent" do content.should = "foo" content.should_not be_safe_insync(:absent) end it "should return false if the file should be a file but is not present" do resource.expects(:should_be_file?).returns true content.should = "foo" content.should_not be_safe_insync(:absent) end describe "and the file exists" do with_digest_algorithms do before do resource.stubs(:stat).returns mock("stat") resource[:checksum] = digest_algorithm content.should = "some content" end it "should return false if the current contents are different from the desired content" do content.should_not be_safe_insync("other content") end it "should return true if the sum for the current contents is the same as the sum for the desired content" do content.must be_safe_insync("{#{digest_algorithm}}" + digest("some content")) end [true, false].product([true, false]).each do |cfg, param| describe "and Puppet[:show_diff] is #{cfg} and show_diff => #{param}" do before do Puppet[:show_diff] = cfg resource.stubs(:show_diff?).returns param resource[:loglevel] = "debug" end if cfg and param it "should display a diff" do content.expects(:diff).returns("my diff").once content.expects(:debug).with("\nmy diff").once content.should_not be_safe_insync("other content") end else it "should not display a diff" do content.expects(:diff).never content.should_not be_safe_insync("other content") end end end end end end describe "and :replace is false" do before do resource.stubs(:replace?).returns false end it "should be insync if the file exists and the content is different" do resource.stubs(:stat).returns mock('stat') content.must be_safe_insync("whatever") end it "should be insync if the file exists and the content is right" do resource.stubs(:stat).returns mock('stat') content.must be_safe_insync("something") end it "should not be insync if the file does not exist" do content.should = "foo" content.should_not be_safe_insync(:absent) end end end describe "when changing the content" do let(:content) { described_class.new(:resource => resource) } before do resource.stubs(:[]).with(:path).returns "/boo" resource.stubs(:stat).returns "eh" end it "should use the file's :write method to write the content" do resource.expects(:write).with(:content) content.sync end it "should return :file_changed if the file already existed" do resource.expects(:stat).returns "something" resource.stubs(:write) content.sync.should == :file_changed end it "should return :file_created if the file did not exist" do resource.expects(:stat).returns nil resource.stubs(:write) content.sync.should == :file_created end end describe "when writing" do let(:content) { described_class.new(:resource => resource) } let(:fh) { File.open(filename, 'wb') } it "should attempt to read from the filebucket if no actual content nor source exists" do content.should = "{md5}foo" content.resource.bucket.class.any_instance.stubs(:getfile).returns "foo" content.write(fh) fh.close end describe "from actual content" do before(:each) do content.stubs(:actual_content).returns("this is content") end it "should write to the given file handle" do fh = mock 'filehandle' fh.expects(:print).with("this is content") content.write(fh) end it "should return the current checksum value" do resource.parameter(:checksum).expects(:sum_stream).returns "checksum" content.write(fh).should == "checksum" end end describe "from a file bucket" do it "should fail if a file bucket cannot be retrieved" do content.should = "{md5}foo" content.resource.expects(:bucket).returns nil expect { content.write(fh) }.to raise_error(Puppet::Error) end it "should fail if the file bucket cannot find any content" do content.should = "{md5}foo" bucket = stub 'bucket' content.resource.expects(:bucket).returns bucket bucket.expects(:getfile).with("foo").raises "foobar" expect { content.write(fh) }.to raise_error(Puppet::Error) end it "should write the returned content to the file" do content.should = "{md5}foo" bucket = stub 'bucket' content.resource.expects(:bucket).returns bucket bucket.expects(:getfile).with("foo").returns "mycontent" fh = mock 'filehandle' fh.expects(:print).with("mycontent") content.write(fh) end end describe "from local source" do let(:source_content) { "source file content\r\n"*10 } before(:each) do sourcename = tmpfile('source') resource[:backup] = false resource[:source] = sourcename File.open(sourcename, 'wb') {|f| f.write source_content} # This needs to be invoked to properly initialize the content property, # or attempting to write a file will fail. resource.newattr(:content) end it "should copy content from the source to the file" do source = resource.parameter(:source) resource.write(source) Puppet::FileSystem.binread(filename).should == source_content end with_digest_algorithms do it "should return the checksum computed" do File.open(filename, 'wb') do |file| resource[:checksum] = digest_algorithm content.write(file).should == "{#{digest_algorithm}}#{digest(source_content)}" end end end end describe 'from remote source' do let(:source_content) { "source file content\n"*10 } let(:source) { resource.newattr(:source) } let(:response) { stub_everything('response') } let(:conn) { mock('connection') } before(:each) do resource[:backup] = false # This needs to be invoked to properly initialize the content property, # or attempting to write a file will fail. resource.newattr(:content) response.stubs(:read_body).multiple_yields(*source_content.lines) conn.stubs(:request_get).yields(response) end it 'should use an explicit fileserver if source starts with puppet://' do response.stubs(:code).returns('200') source.stubs(:metadata).returns stub_everything('metadata', :source => 'puppet://somehostname/test/foo', :ftype => 'file') Puppet::Network::HttpPool.expects(:http_instance).with('somehostname', anything).returns(conn) resource.write(source) end it 'should use the default fileserver if source starts with puppet:///' do response.stubs(:code).returns('200') source.stubs(:metadata).returns stub_everything('metadata', :source => 'puppet:///test/foo', :ftype => 'file') Puppet::Network::HttpPool.expects(:http_instance).with(Puppet.settings[:server], anything).returns(conn) resource.write(source) end it 'should percent encode reserved characters' do response.stubs(:code).returns('200') Puppet::Network::HttpPool.stubs(:http_instance).returns(conn) source.stubs(:metadata).returns stub_everything('metadata', :source => 'puppet:///test/foo bar', :ftype => 'file') conn.unstub(:request_get) - conn.expects(:request_get).with("#{Puppet[:master_url_prefix]}/v3/file_content/test/foo%20bar?environment=testing&", anything).yields(response) + conn.expects(:request_get).with("#{Puppet::Network::HTTP::MASTER_URL_PREFIX}/v3/file_content/test/foo%20bar?environment=testing&", anything).yields(response) resource.write(source) end describe 'when handling file_content responses' do before(:each) do Puppet::Network::HttpPool.stubs(:http_instance).returns(conn) source.stubs(:metadata).returns stub_everything('metadata', :source => 'puppet:///test/foo', :ftype => 'file') end it 'should not write anything if source is not found' do response.stubs(:code).returns('404') expect { resource.write(source) }.to raise_error(Net::HTTPError, /404/) expect(File.read(filename)).to eq('initial file content') end it 'should raise an HTTP error in case of server error' do response.stubs(:code).returns('500') expect { resource.write(source) }.to raise_error(Net::HTTPError, /500/) end context 'and the request was successful' do before(:each) { response.stubs(:code).returns '200' } it 'should write the contents to the file' do resource.write(source) expect(Puppet::FileSystem.binread(filename)).to eq(source_content) end with_digest_algorithms do it 'should return the checksum computed' do File.open(filename, 'w') do |file| resource[:checksum] = digest_algorithm expect(content.write(file)).to eq("{#{digest_algorithm}}#{digest(source_content)}") end end end end end end # These are testing the implementation rather than the desired behaviour; while that bites, there are a whole # pile of other methods in the File type that depend on intimate details of this implementation and vice-versa. # If these blow up, you are gonna have to review the callers to make sure they don't explode! --daniel 2011-02-01 describe "each_chunk_from should work" do it "when content is a string" do content.each_chunk_from('i_am_a_string') { |chunk| chunk.should == 'i_am_a_string' } end # The following manifest is a case where source and content.should are both set # file { "/tmp/mydir" : # source => '/tmp/sourcedir', # recurse => true, # } it "when content checksum comes from source" do source_param = Puppet::Type.type(:file).attrclass(:source) source = source_param.new(:resource => resource) content.should = "{md5}123abcd" content.expects(:chunk_file_from_source).returns('from_source') content.each_chunk_from(source) { |chunk| chunk.should == 'from_source' } end it "when no content, source, but ensure present" do resource[:ensure] = :present content.each_chunk_from(nil) { |chunk| chunk.should == '' } end # you might do this if you were just auditing it "when no content, source, but ensure file" do resource[:ensure] = :file content.each_chunk_from(nil) { |chunk| chunk.should == '' } end it "when source_or_content is nil and content not a checksum" do content.each_chunk_from(nil) { |chunk| chunk.should == '' } end # the content is munged so that if it's a checksum nil gets passed in it "when content is a checksum it should try to read from filebucket" do content.should = "{md5}123abcd" content.expects(:read_file_from_filebucket).once.returns('im_a_filebucket') content.each_chunk_from(nil) { |chunk| chunk.should == 'im_a_filebucket' } end it "when running as puppet apply" do Puppet[:default_file_terminus] = "file_server" source_or_content = stubs('source_or_content') source_or_content.expects(:content).once.returns :whoo content.each_chunk_from(source_or_content) { |chunk| chunk.should == :whoo } end it "when running from source with a local file" do source_or_content = stubs('source_or_content') source_or_content.expects(:local?).returns true content.expects(:chunk_file_from_disk).with(source_or_content).once.yields 'woot' content.each_chunk_from(source_or_content) { |chunk| chunk.should == 'woot' } end it "when running from source with a remote file" do source_or_content = stubs('source_or_content') source_or_content.expects(:local?).returns false content.expects(:chunk_file_from_source).with(source_or_content).once.yields 'woot' content.each_chunk_from(source_or_content) { |chunk| chunk.should == 'woot' } end end end end