diff options
author | Christian Hofstaedtler <hofstaedtler@inqnet.at> | 2009-04-28 12:23:58 +0000 |
---|---|---|
committer | James Turnbull <james@lovedthanlost.net> | 2009-05-02 09:13:29 +1000 |
commit | 6e01e7ab403d090f29f13c938ca5b19930c4b408 (patch) | |
tree | 413dd59275768143ace1161a43dccc640744ccfc | |
parent | cc09c1af21e218917cc02c0ad9d8c44300803f49 (diff) | |
download | puppet-6e01e7ab403d090f29f13c938ca5b19930c4b408.tar.gz puppet-6e01e7ab403d090f29f13c938ca5b19930c4b408.tar.xz puppet-6e01e7ab403d090f29f13c938ca5b19930c4b408.zip |
Puppet as a Rack application
This lays the ground: a wrapper for the REST handler, and an application
confirming to the Rack standard. Also includes a base class for Rack
handlers, as RackREST will not stay the only one, and there needs to be
a central place where client authentication data can be checked.
-rw-r--r-- | lib/puppet/feature/base.rb | 3 | ||||
-rw-r--r-- | lib/puppet/network/http/rack.rb | 45 | ||||
-rw-r--r-- | lib/puppet/network/http/rack/httphandler.rb | 16 | ||||
-rw-r--r-- | lib/puppet/network/http/rack/rest.rb | 74 | ||||
-rwxr-xr-x | spec/unit/network/http/rack.rb | 71 | ||||
-rwxr-xr-x | spec/unit/network/http/rack/rest.rb | 194 |
6 files changed, 403 insertions, 0 deletions
diff --git a/lib/puppet/feature/base.rb b/lib/puppet/feature/base.rb index c3fb9a2f3..7c0f241c1 100644 --- a/lib/puppet/feature/base.rb +++ b/lib/puppet/feature/base.rb @@ -28,3 +28,6 @@ Puppet.features.add(:augeas, :libs => ["augeas"]) # We have RRD available Puppet.features.add(:rrd, :libs => ["RRDtool"]) + +# We have rack available, an HTTP Application Stack +Puppet.features.add(:rack, :libs => ["rack"]) diff --git a/lib/puppet/network/http/rack.rb b/lib/puppet/network/http/rack.rb new file mode 100644 index 000000000..58f49416b --- /dev/null +++ b/lib/puppet/network/http/rack.rb @@ -0,0 +1,45 @@ + +require 'rack' +require 'puppet/network/http' +require 'puppet/network/http/rack/rest' + +# An rack application, for running the Puppet HTTP Server. +class Puppet::Network::HTTP::Rack + + def initialize(args) + raise ArgumentError, ":protocols must be specified." if !args[:protocols] or args[:protocols].empty? + protocols = args[:protocols] + + # Always prepare a REST handler + @rest_http_handler = Puppet::Network::HTTP::RackREST.new() + protocols.delete :rest + + raise ArgumentError, "there were unknown :protocols specified." if !protocols.empty? + end + + # The real rack application (which needs to respond to call). + # The work we need to do, roughly is: + # * Read request (from env) and prepare a response + # * Route the request to the correct handler + # * Return the response (in rack-format) to our caller. + def call(env) + request = Rack::Request.new(env) + response = Rack::Response.new() + Puppet.debug 'Handling request: %s %s' % [request.request_method, request.fullpath] + + begin + @rest_http_handler.process(request, response) + rescue => detail + # Send a Status 500 Error on unhandled exceptions. + response.status = 500 + response['Content-Type'] = 'text/plain' + response.write 'Internal Server Error: "%s"' % detail.message + # log what happened + Puppet.err "Puppet Server (Rack): Internal Server Error: Unhandled Exception: \"%s\"" % detail.message + Puppet.err "Backtrace:" + detail.backtrace.each { |line| Puppet.err " > %s" % line } + end + response.finish() + end +end + diff --git a/lib/puppet/network/http/rack/httphandler.rb b/lib/puppet/network/http/rack/httphandler.rb new file mode 100644 index 000000000..e14206850 --- /dev/null +++ b/lib/puppet/network/http/rack/httphandler.rb @@ -0,0 +1,16 @@ +require 'openssl' +require 'puppet/ssl/certificate' + +class Puppet::Network::HTTP::RackHttpHandler + + def initialize() + end + + # do something useful with request (a Rack::Request) and use + # response to fill your Rack::Response + def process(request, response) + raise NotImplementedError, "Your RackHttpHandler subclass is supposed to override service(request)" + end + +end + diff --git a/lib/puppet/network/http/rack/rest.rb b/lib/puppet/network/http/rack/rest.rb new file mode 100644 index 000000000..e98bffc1e --- /dev/null +++ b/lib/puppet/network/http/rack/rest.rb @@ -0,0 +1,74 @@ +require 'puppet/network/http/handler' +require 'puppet/network/http/rack/httphandler' + +class Puppet::Network::HTTP::RackREST < Puppet::Network::HTTP::RackHttpHandler + + include Puppet::Network::HTTP::Handler + + HEADER_ACCEPT = 'HTTP_ACCEPT'.freeze + ContentType = 'Content-Type'.freeze + + def initialize(args={}) + super() + initialize_for_puppet(args) + end + + def set_content_type(response, format) + response[ContentType] = format + end + + # produce the body of the response + def set_response(response, result, status = 200) + response.status = status + response.write result + end + + # Retrieve the accept header from the http request. + def accept_header(request) + request.env[HEADER_ACCEPT] + 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) + result = decode_params(request.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 + # request.body has some limitiations, so we need to concat it back + # into a regular string, which is something puppet can use. + def body(request) + body = '' + request.body.each { |part| body += part } + body + 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. + # try this with :ssl_client_header, which defaults should work for + # Apache with StdEnvVars. + if dn = request.env[Puppet[:ssl_client_header]] and dn_matchdata = dn.match(/^.*?CN\s*=\s*(.*)/) + result[:node] = dn_matchdata[1].to_str + result[:authenticated] = (request.env[Puppet[:ssl_client_verify_header]] == 'SUCCESS') + else + result[:node] = resolve_node(result) + result[:authenticated] = false + end + + result + end + +end diff --git a/spec/unit/network/http/rack.rb b/spec/unit/network/http/rack.rb new file mode 100755 index 000000000..3d5e4fe63 --- /dev/null +++ b/spec/unit/network/http/rack.rb @@ -0,0 +1,71 @@ +#!/usr/bin/env ruby + +require File.dirname(__FILE__) + '/../../../spec_helper' +require 'puppet/network/http/rack' + +describe "Puppet::Network::HTTP::Rack" do + confine "Rack is not available" => Puppet.features.rack? + + describe "while initializing" do + + it "should require a protocol specification" do + Proc.new { Puppet::Network::HTTP::Rack.new({}) }.should raise_error(ArgumentError) + end + + it "should not accept imaginary protocols" do + Proc.new { Puppet::Network::HTTP::Rack.new({:protocols => [:foo]}) }.should raise_error(ArgumentError) + end + + it "should accept the REST protocol" do + Proc.new { Puppet::Network::HTTP::Rack.new({:protocols => [:rest]}) }.should_not raise_error(ArgumentError) + end + + it "should create a RackREST instance" do + Puppet::Network::HTTP::RackREST.expects(:new) + Puppet::Network::HTTP::Rack.new({:protocols => [:rest]}) + end + + end + + describe "when called" do + + before :all do + @app = Puppet::Network::HTTP::Rack.new({:protocols => [:rest]}) + # let's use Rack::Lint to verify that we're OK with the rack specification + @linted = Rack::Lint.new(@app) + end + + before :each do + @env = Rack::MockRequest.env_for('/') + end + + it "should create a Request object" do + request = Rack::Request.new(@env) + Rack::Request.expects(:new).returns request + @linted.call(@env) + end + + it "should create a Response object" do + Rack::Response.expects(:new).returns stub_everything + @app.call(@env) # can't lint when Rack::Response is a stub + end + + it "should let RackREST process the request" do + Puppet::Network::HTTP::RackREST.any_instance.expects(:process).once + @linted.call(@env) + end + + it "should catch unhandled exceptions from RackREST" do + Puppet::Network::HTTP::RackREST.any_instance.expects(:process).raises(ArgumentError, 'test error') + Proc.new { @linted.call(@env) }.should_not raise_error + end + + it "should finish() the Response" do + Rack::Response.any_instance.expects(:finish).once + @app.call(@env) # can't lint when finish is a stub + end + + end + +end + diff --git a/spec/unit/network/http/rack/rest.rb b/spec/unit/network/http/rack/rest.rb new file mode 100755 index 000000000..873483782 --- /dev/null +++ b/spec/unit/network/http/rack/rest.rb @@ -0,0 +1,194 @@ +#!/usr/bin/env ruby + +require File.dirname(__FILE__) + '/../../../../spec_helper' +require 'puppet/network/http/rack' +require 'puppet/network/http/rack/rest' + +describe "Puppet::Network::HTTP::RackREST" do + confine "Rack is not available" => Puppet.features.rack? + + it "should include the Puppet::Network::HTTP::Handler module" do + Puppet::Network::HTTP::RackREST.ancestors.should be_include(Puppet::Network::HTTP::Handler) + end + + describe "when initializing" do + it "should call the Handler's initialization hook with its provided arguments" do + Puppet::Network::HTTP::RackREST.any_instance.expects(:initialize_for_puppet).with(:server => "my", :handler => "arguments") + Puppet::Network::HTTP::RackREST.new(:server => "my", :handler => "arguments") + end + end + + describe "when serving a request" do + before :all do + @model_class = stub('indirected model class') + Puppet::Indirector::Indirection.stubs(:model).with(:foo).returns(@model_class) + @handler = Puppet::Network::HTTP::RackREST.new(:handler => :foo) + end + + before :each do + @response = Rack::Response.new() + end + + def mk_req(uri, opts = {}) + env = Rack::MockRequest.env_for(uri, opts) + Rack::Request.new(env) + end + + describe "and using the HTTP Handler interface" do + it "should return the HTTP_ACCEPT parameter as the accept header" do + req = mk_req('/', 'HTTP_ACCEPT' => 'myaccept') + @handler.accept_header(req).should == "myaccept" + end + + it "should use the REQUEST_METHOD as the http method" do + req = mk_req('/', :method => 'mymethod') + @handler.http_method(req).should == "mymethod" + end + + it "should return the request path as the path" do + req = mk_req('/foo/bar') + @handler.path(req).should == "/foo/bar" + end + + it "should return the request body as the body" do + req = mk_req('/foo/bar', :input => 'mybody') + @handler.body(req).should == "mybody" + end + + it "should set the response's content-type header when setting the content type" do + @header = mock 'header' + @response.expects(:header).returns @header + @header.expects(:[]=).with('Content-Type', "mytype") + + @handler.set_content_type(@response, "mytype") + end + + it "should set the status and write the body when setting the response for a request" do + @response.expects(:status=).with(400) + @response.expects(:write).with("mybody") + + @handler.set_response(@response, "mybody", 400) + end + end + + describe "and determining the request parameters" do + it "should include the HTTP request parameters, with the keys as symbols" do + req = mk_req('/?foo=baz&bar=xyzzy') + result = @handler.params(req) + result[:foo].should == "baz" + result[:bar].should == "xyzzy" + end + + it "should URI-decode the HTTP parameters" do + encoding = URI.escape("foo bar") + req = mk_req("/?foo=#{encoding}") + result = @handler.params(req) + result[:foo].should == "foo bar" + end + + it "should convert the string 'true' to the boolean" do + req = mk_req("/?foo=true") + result = @handler.params(req) + result[:foo].should be_true + end + + it "should convert the string 'false' to the boolean" do + req = mk_req("/?foo=false") + result = @handler.params(req) + result[:foo].should be_false + end + + it "should convert integer arguments to Integers" do + req = mk_req("/?foo=15") + result = @handler.params(req) + result[:foo].should == 15 + end + + it "should convert floating point arguments to Floats" do + req = mk_req("/?foo=1.5") + result = @handler.params(req) + result[:foo].should == 1.5 + end + + it "should YAML-load and URI-decode values that are YAML-encoded" do + escaping = URI.escape(YAML.dump(%w{one two})) + req = mk_req("/?foo=#{escaping}") + result = @handler.params(req) + result[:foo].should == %w{one two} + end + + it "should not allow the client to set the node via the query string" do + req = mk_req("/?node=foo") + @handler.params(req)[:node].should be_nil + end + + it "should not allow the client to set the IP address via the query string" do + req = mk_req("/?ip=foo") + @handler.params(req)[:ip].should be_nil + end + + it "should pass the client's ip address to model find" do + req = mk_req("/", 'REMOTE_ADDR' => 'ipaddress') + @handler.params(req)[:ip].should == "ipaddress" + end + + it "should set 'authenticated' to false if no certificate is present" do + req = mk_req('/') + @handler.params(req)[:authenticated].should be_false + end + end + + describe "with pre-validated certificates" do + + it "should use the :ssl_client_header to determine the parameter when looking for the certificate" do + Puppet.settings.stubs(:value).returns "eh" + Puppet.settings.expects(:value).with(:ssl_client_header).returns "myheader" + req = mk_req('/', "myheader" => "/CN=host.domain.com") + @handler.params(req) + end + + it "should retrieve the hostname by matching the certificate parameter" do + Puppet.settings.stubs(:value).returns "eh" + Puppet.settings.expects(:value).with(:ssl_client_header).returns "myheader" + req = mk_req('/', "myheader" => "/CN=host.domain.com") + @handler.params(req)[:node].should == "host.domain.com" + end + + it "should use the :ssl_client_header to determine the parameter for checking whether the host certificate is valid" do + Puppet.settings.stubs(:value).with(:ssl_client_header).returns "certheader" + Puppet.settings.expects(:value).with(:ssl_client_verify_header).returns "myheader" + req = mk_req('/', "myheader" => "SUCCESS", "certheader" => "/CN=host.domain.com") + @handler.params(req) + end + + it "should consider the host authenticated if the validity parameter contains 'SUCCESS'" do + Puppet.settings.stubs(:value).with(:ssl_client_header).returns "certheader" + Puppet.settings.stubs(:value).with(:ssl_client_verify_header).returns "myheader" + req = mk_req('/', "myheader" => "SUCCESS", "certheader" => "/CN=host.domain.com") + @handler.params(req)[:authenticated].should be_true + end + + it "should consider the host unauthenticated if the validity parameter does not contain 'SUCCESS'" do + Puppet.settings.stubs(:value).with(:ssl_client_header).returns "certheader" + Puppet.settings.stubs(:value).with(:ssl_client_verify_header).returns "myheader" + req = mk_req('/', "myheader" => "whatever", "certheader" => "/CN=host.domain.com") + @handler.params(req)[:authenticated].should be_false + end + + it "should consider the host unauthenticated if no certificate information is present" do + Puppet.settings.stubs(:value).with(:ssl_client_header).returns "certheader" + Puppet.settings.stubs(:value).with(:ssl_client_verify_header).returns "myheader" + req = mk_req('/', "myheader" => nil, "certheader" => "/CN=host.domain.com") + @handler.params(req)[:authenticated].should be_false + end + + it "should resolve the node name with an ip address look-up if no certificate is present" do + Puppet.settings.stubs(:value).returns "eh" + Puppet.settings.expects(:value).with(:ssl_client_header).returns "myheader" + req = mk_req('/', "myheader" => nil) + @handler.expects(:resolve_node).returns("host.domain.com") + @handler.params(req)[:node].should == "host.domain.com" + end + end + end +end |