commit a803b2684bad5fba4f00ccffb1378f7bca40c26d Author: Praveen Arimbrathodiyil Date: Thu Apr 6 13:02:53 2017 +0530 Import Upstream version 0.6.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f30a35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +pkg/* +*.gem +.bundle diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f029555 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +sudo: false +cache: bundler +language: ruby +before_install: + - "echo 'gem: --no-ri --no-rdoc' > ~/.gemrc" + - gem install bundler + - gem update bundler +script: bundle exec rake test +rvm: + - 2.0.0 + - 2.1.5 + - 2.2.2 + - 2.2.3 + - 2.3.0 + - 2.3.1 +env: + - RAILS_ENV=test RACK_ENV=test +notifications: + email: false diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..613c8c1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source "http://rubygems.org" + +gem 'rake' + +# Specify your gem's dependencies in rack-proxy.gemspec +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..a8765bf --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,28 @@ +PATH + remote: . + specs: + rack-proxy (0.6.1) + rack + +GEM + remote: http://rubygems.org/ + specs: + power_assert (0.2.6) + rack (1.2.1) + rack-test (0.5.6) + rack (>= 1.0) + rake (0.9.2.2) + test-unit (3.1.5) + power_assert + +PLATFORMS + ruby + +DEPENDENCIES + rack-proxy! + rack-test + rake + test-unit + +BUNDLED WITH + 1.14.6 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8356415 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Jacek Becela jacek.becela@gmail.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..31b515b --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +A request/response rewriting HTTP proxy. A Rack app. Subclass `Rack::Proxy` and provide your `rewrite_env` and `rewrite_response` methods. + +Example +------- + +```ruby +class Foo < Rack::Proxy + + def rewrite_env(env) + env["HTTP_HOST"] = "example.com" + + env + end + + def rewrite_response(triplet) + status, headers, body = triplet + + headers["X-Foo"] = "Bar" + + triplet + end + +end +``` + +### Disable SSL session verification when proxying a server with e.g. self-signed SSL certs + +```ruby +class TrustingProxy < Rack::Proxy + + def rewrite_env(env) + env["rack.ssl_verify_none"] = true + + env + end + +end +``` + +The same can be achieved for *all* requests going through the `Rack::Proxy` instance by using + +```ruby +Rack::Proxy.new(ssl_verify_none: true) +``` + +Using it as a middleware: +------------------------- + +Example: Proxying only requests that end with ".php" could be done like this: + +```ruby +require 'rack/proxy' +class RackPhpProxy < Rack::Proxy + + def perform_request(env) + request = Rack::Request.new(env) + if request.path =~ %r{\.php} + env["HTTP_HOST"] = "localhost" + env["REQUEST_PATH"] = "/php/#{request.fullpath}" + super(env) + else + @app.call(env) + end + end +end +``` + +To use the middleware, please consider the following: + +1) For Rails we could add a configuration in config/application.rb + +```ruby + config.middleware.use RackPhpProxy, {ssl_verify_none: true} +``` + +2) For Sinatra or any Rack-based application: + +```ruby +class MyAwesomeSinatra < Sinatra::Base + use RackPhpProxy, {ssl_verify_none: true} +end +``` + +This will allow to run the other requests through the application and only proxy the requests that match the condition from the middleware. + +See tests for more examples. + +WARNING +------- + +Doesn't work with fakeweb/webmock. Both libraries monkey-patch net/http code. + +Todos +----- + +- Make the docs up to date with the current use case for this code: everything except streaming which involved a rather ugly monkey patch and only worked in 1.8, but does not work now. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..45a2ca6 --- /dev/null +++ b/Rakefile @@ -0,0 +1,14 @@ +require 'rubygems' +require 'bundler' +Bundler::GemHelper.install_tasks + +require "rake/testtask" +task :test do + Rake::TestTask.new do |t| + t.libs << "test" + t.test_files = FileList['test/*_test.rb'] + t.verbose = true + end +end + +task :default => :test diff --git a/lib/net_http_hacked.rb b/lib/net_http_hacked.rb new file mode 100644 index 0000000..2585fea --- /dev/null +++ b/lib/net_http_hacked.rb @@ -0,0 +1,90 @@ +# We are hacking net/http to change semantics of streaming handling +# from "block" semantics to regular "return" semnatics. +# We need it to construct a streamable rack triplet: +# +# [status, headers, streamable_body] +# +# See http://github.com/aniero/rack-streaming-proxy +# for alternative that uses additional process. +# +# BTW I don't like monkey patching either +# but this is not real monkey patching. +# I just added some methods and named them very uniquely +# to avoid eventual conflicts. You're safe. Trust me. +# +# Also, in Ruby 1.9.2 you could use Fibers to avoid hacking net/http. + +require 'net/https' + +class Net::HTTP + # Original #request with block semantics. + # + # def request(req, body = nil, &block) + # unless started? + # start { + # req['connection'] ||= 'close' + # return request(req, body, &block) + # } + # end + # if proxy_user() + # unless use_ssl? + # req.proxy_basic_auth proxy_user(), proxy_pass() + # end + # end + # + # req.set_body_internal body + # begin_transport req + # req.exec @socket, @curr_http_version, edit_path(req.path) + # begin + # res = HTTPResponse.read_new(@socket) + # end while res.kind_of?(HTTPContinue) + # res.reading_body(@socket, req.response_body_permitted?) { + # yield res if block_given? + # } + # end_transport req, res + # + # res + # end + + def begin_request_hacked(req) + begin_transport req + req.exec @socket, @curr_http_version, edit_path(req.path) + begin + res = Net::HTTPResponse.read_new(@socket) + end while res.kind_of?(Net::HTTPContinue) + res.begin_reading_body_hacked(@socket, req.response_body_permitted?) + @req_hacked, @res_hacked = req, res + @res_hacked + end + + def end_request_hacked + @res_hacked.end_reading_body_hacked + end_transport @req_hacked, @res_hacked + @res_hacked + end +end + +class Net::HTTPResponse + # Original #reading_body with block semantics + # + # def reading_body(sock, reqmethodallowbody) #:nodoc: internal use only + # @socket = sock + # @body_exist = reqmethodallowbody && self.class.body_permitted? + # begin + # yield + # self.body # ensure to read body + # ensure + # @socket = nil + # end + # end + + def begin_reading_body_hacked(sock, reqmethodallowbody) + @socket = sock + @body_exist = reqmethodallowbody && self.class.body_permitted? + end + + def end_reading_body_hacked + self.body + @socket = nil + end +end diff --git a/lib/rack-proxy.rb b/lib/rack-proxy.rb new file mode 100644 index 0000000..3d7a81c --- /dev/null +++ b/lib/rack-proxy.rb @@ -0,0 +1 @@ +require "rack/proxy" \ No newline at end of file diff --git a/lib/rack/http_streaming_response.rb b/lib/rack/http_streaming_response.rb new file mode 100644 index 0000000..e6a20f0 --- /dev/null +++ b/lib/rack/http_streaming_response.rb @@ -0,0 +1,77 @@ +require "net_http_hacked" + +module Rack + + # Wraps the hacked net/http in a Rack way. + class HttpStreamingResponse + attr_accessor :use_ssl + attr_accessor :verify_mode + attr_accessor :read_timeout + attr_accessor :ssl_version + + def initialize(request, host, port = nil) + @request, @host, @port = request, host, port + end + + def body + self + end + + def code + response.code.to_i + end + # #status is deprecated + alias_method :status, :code + + def headers + h = Utils::HeaderHash.new + + response.to_hash.each do |k, v| + h[k] = v + end + + h + end + + # Can be called only once! + def each(&block) + response.read_body(&block) + ensure + session.end_request_hacked + session.finish + end + + def to_s + @body ||= begin + lines = [] + + each do |line| + lines << line + end + + lines.join + end + end + + protected + + # Net::HTTPResponse + def response + @response ||= session.begin_request_hacked(@request) + end + + # Net::HTTP + def session + @session ||= begin + http = Net::HTTP.new @host, @port + http.use_ssl = self.use_ssl + http.verify_mode = self.verify_mode + http.read_timeout = self.read_timeout + http.ssl_version = self.ssl_version if self.use_ssl + http.start + end + end + + end + +end diff --git a/lib/rack/proxy.rb b/lib/rack/proxy.rb new file mode 100644 index 0000000..0e3dfbf --- /dev/null +++ b/lib/rack/proxy.rb @@ -0,0 +1,129 @@ +require "net_http_hacked" +require "rack/http_streaming_response" + +module Rack + + # Subclass and bring your own #rewrite_request and #rewrite_response + class Proxy + VERSION = "0.6.1" + + class << self + def extract_http_request_headers(env) + headers = env.reject do |k, v| + !(/^HTTP_[A-Z0-9_]+$/ === k) || v.nil? + end.map do |k, v| + [reconstruct_header_name(k), v] + end.inject(Utils::HeaderHash.new) do |hash, k_v| + k, v = k_v + hash[k] = v + hash + end + + x_forwarded_for = (headers["X-Forwarded-For"].to_s.split(/, +/) << env["REMOTE_ADDR"]).join(", ") + + headers.merge!("X-Forwarded-For" => x_forwarded_for) + end + + def normalize_headers(headers) + mapped = headers.map do |k, v| + [k, if v.is_a? Array then v.join("\n") else v end] + end + Utils::HeaderHash.new Hash[mapped] + end + + protected + + def reconstruct_header_name(name) + name.sub(/^HTTP_/, "").gsub("_", "-") + end + end + + # @option opts [String, URI::HTTP] :backend Backend host to proxy requests to + def initialize(app = nil, opts= {}) + if app.is_a?(Hash) + opts = app + @app = nil + else + @app = app + end + @streaming = opts.fetch(:streaming, true) + @ssl_verify_none = opts.fetch(:ssl_verify_none, false) + @backend = URI(opts[:backend]) if opts[:backend] + @read_timeout = opts.fetch(:read_timeout, 60) + @ssl_version = opts[:ssl_version] if opts[:ssl_version] + end + + def call(env) + rewrite_response(perform_request(rewrite_env(env))) + end + + # Return modified env + def rewrite_env(env) + env + end + + # Return a rack triplet [status, headers, body] + def rewrite_response(triplet) + triplet + end + + protected + + def perform_request(env) + source_request = Rack::Request.new(env) + + # Initialize request + if source_request.fullpath == "" + full_path = URI.parse(env['REQUEST_URI']).request_uri + else + full_path = source_request.fullpath + end + + target_request = Net::HTTP.const_get(source_request.request_method.capitalize).new(full_path) + + # Setup headers + target_request.initialize_http_header(self.class.extract_http_request_headers(source_request.env)) + + # Setup body + if target_request.request_body_permitted? && source_request.body + target_request.body_stream = source_request.body + target_request.content_length = source_request.content_length.to_i + target_request.content_type = source_request.content_type if source_request.content_type + target_request.body_stream.rewind + end + + backend = env.delete('rack.backend') || @backend || source_request + use_ssl = backend.scheme == "https" + ssl_verify_none = (env.delete('rack.ssl_verify_none') || @ssl_verify_none) == true + read_timeout = env.delete('http.read_timeout') || @read_timeout + + # Create the response + if @streaming + # streaming response (the actual network communication is deferred, a.k.a. streamed) + target_response = HttpStreamingResponse.new(target_request, backend.host, backend.port) + target_response.use_ssl = use_ssl + target_response.read_timeout = read_timeout + target_response.verify_mode = OpenSSL::SSL::VERIFY_NONE if use_ssl && ssl_verify_none + target_response.ssl_version = @ssl_version if @ssl_version + else + http = Net::HTTP.new(backend.host, backend.port) + http.use_ssl = use_ssl if use_ssl + http.read_timeout = read_timeout + http.verify_mode = OpenSSL::SSL::VERIFY_NONE if use_ssl && ssl_verify_none + http.ssl_version = @ssl_version if @ssl_version + + target_response = http.start do + http.request(target_request) + end + end + + headers = (target_response.respond_to?(:headers) && target_response.headers) || self.class.normalize_headers(target_response.to_hash) + body = target_response.body || [""] + body = [body] unless body.respond_to?(:each) + + [target_response.code, headers, body] + end + + end + +end diff --git a/rack-proxy.gemspec b/rack-proxy.gemspec new file mode 100644 index 0000000..a7209fb --- /dev/null +++ b/rack-proxy.gemspec @@ -0,0 +1,25 @@ +# -*- encoding: utf-8 -*- +$:.push File.expand_path("../lib", __FILE__) +require "rack-proxy" + +Gem::Specification.new do |s| + s.name = "rack-proxy" + s.version = Rack::Proxy::VERSION + s.platform = Gem::Platform::RUBY + s.authors = ["Jacek Becela"] + s.email = ["jacek.becela@gmail.com"] + s.homepage = "http://rubygems.org/gems/rack-proxy" + s.summary = %q{A request/response rewriting HTTP proxy. A Rack app.} + s.description = %q{A Rack app that provides request/response rewriting proxy capabilities with streaming.} + + s.rubyforge_project = "rack-proxy" + + s.files = `git ls-files`.split("\n") + s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") + s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } + s.require_paths = ["lib"] + + s.add_dependency("rack") + s.add_development_dependency("rack-test") + s.add_development_dependency("test-unit") +end diff --git a/test/http_streaming_response_test.rb b/test/http_streaming_response_test.rb new file mode 100644 index 0000000..395bd95 --- /dev/null +++ b/test/http_streaming_response_test.rb @@ -0,0 +1,47 @@ +require "test_helper" +require "rack/http_streaming_response" + +class HttpStreamingResponseTest < Test::Unit::TestCase + + def setup + host, req = "www.trix.pl", Net::HTTP::Get.new("/") + @response = Rack::HttpStreamingResponse.new(req, host) + end + + def test_streaming + # Response status + assert @response.code == 200 + assert @response.status == 200 + + # Headers + headers = @response.headers + + assert headers.size > 0 + + assert headers["content-type"] == ["text/html;charset=utf-8"] + assert headers["CoNtEnT-TyPe"] == headers["content-type"] + assert headers["content-length"].first.to_i > 0 + + # Body + chunks = [] + @response.body.each do |chunk| + chunks << chunk + end + + assert chunks.size > 0 + chunks.each do |chunk| + assert chunk.is_a?(String) + end + + end + + def test_to_s + assert_equal @response.headers["Content-Length"].first.to_i, @response.body.to_s.size + end + + def test_to_s_called_twice + body = @response.body + assert_equal body.to_s, body.to_s + end + +end diff --git a/test/net_http_hacked_test.rb b/test/net_http_hacked_test.rb new file mode 100644 index 0000000..03ce0a1 --- /dev/null +++ b/test/net_http_hacked_test.rb @@ -0,0 +1,36 @@ +require "test_helper" +require "net_http_hacked" + +class NetHttpHackedTest < Test::Unit::TestCase + + def test_net_http_hacked + req = Net::HTTP::Get.new("/") + http = Net::HTTP.start("www.iana.org", "80") + + # Response code + res = http.begin_request_hacked(req) + assert res.code == "200" + + # Headers + headers = {} + res.each_header { |k, v| headers[k] = v } + + assert headers.size > 0 + assert headers["content-type"] == "text/html; charset=UTF-8" + assert !headers["date"].nil? + + # Body + chunks = [] + res.read_body do |chunk| + chunks << chunk + end + + assert chunks.size > 0 + chunks.each do |chunk| + assert chunk.is_a?(String) + end + + http.end_request_hacked + end + +end diff --git a/test/rack_proxy_test.rb b/test/rack_proxy_test.rb new file mode 100644 index 0000000..ef2bf20 --- /dev/null +++ b/test/rack_proxy_test.rb @@ -0,0 +1,118 @@ +require "test_helper" +require "rack/proxy" + +class RackProxyTest < Test::Unit::TestCase + class HostProxy < Rack::Proxy + attr_accessor :host + + def rewrite_env(env) + env["HTTP_HOST"] = self.host || 'www.trix.pl' + env + end + end + + def app(opts = {}) + return @app ||= HostProxy.new(opts) + end + + def test_http_streaming + get "/" + assert last_response.ok? + assert_match(/Jacek Becela/, last_response.body) + end + + def test_http_full_request + app(:streaming => false) + get "/" + assert last_response.ok? + assert_match(/Jacek Becela/, last_response.body) + end + + def test_http_full_request_headers + app(:streaming => false) + app.host = 'www.google.com' + get "/" + assert !Array(last_response['Set-Cookie']).empty?, 'Google always sets a cookie, yo. Where my cookies at?!' + end + + def test_https_streaming + app.host = 'www.apple.com' + get 'https://example.com' + assert last_response.ok? + assert_match(/(itunes|iphone|ipod|mac|ipad)/, last_response.body) + end + + def test_https_streaming_tls + app(:ssl_version => :TLSv1).host = 'www.apple.com' + get 'https://example.com' + assert last_response.ok? + assert_match(/(itunes|iphone|ipod|mac|ipad)/, last_response.body) + end + + def test_https_full_request + app(:streaming => false).host = 'www.apple.com' + get 'https://example.com' + assert last_response.ok? + assert_match(/(itunes|iphone|ipod|mac|ipad)/, last_response.body) + end + + def test_https_full_request_tls + app({:streaming => false, :ssl_version => :TLSv1}).host = 'www.apple.com' + get 'https://example.com' + assert last_response.ok? + assert_match(/(itunes|iphone|ipod|mac|ipad)/, last_response.body) + end + + def test_normalize_headers + proxy_class = Rack::Proxy + headers = { 'header_array' => ['first_entry'], 'header_non_array' => :entry } + + normalized_headers = proxy_class.send(:normalize_headers, headers) + assert normalized_headers.instance_of?(Rack::Utils::HeaderHash) + assert normalized_headers['header_array'] == 'first_entry' + assert normalized_headers['header_non_array'] == :entry + end + + def test_header_reconstruction + proxy_class = Rack::Proxy + + header = proxy_class.send(:reconstruct_header_name, "HTTP_ABC") + assert header == "ABC" + + header = proxy_class.send(:reconstruct_header_name, "HTTP_ABC_D") + assert header == "ABC-D" + end + + def test_extract_http_request_headers + proxy_class = Rack::Proxy + env = { + 'NOT-HTTP-HEADER' => 'test-value', + 'HTTP_ACCEPT' => 'text/html', + 'HTTP_CONNECTION' => nil, + 'HTTP_CONTENT_MD5' => 'deadbeef' + } + + headers = proxy_class.extract_http_request_headers(env) + assert headers.key?('ACCEPT') + assert headers.key?('CONTENT-MD5') + assert !headers.key?('CONNECTION') + assert !headers.key?('NOT-HTTP-HEADER') + end + + def test_duplicate_headers + proxy_class = Rack::Proxy + env = { 'Set-Cookie' => ["cookie1=foo", "cookie2=bar"] } + + headers = proxy_class.normalize_headers(env) + assert headers['Set-Cookie'].include?('cookie1=foo'), "Include the first value" + assert headers['Set-Cookie'].include?("\n"), "Join multiple cookies with newlines" + assert headers['Set-Cookie'].include?('cookie2=bar'), "Include the second value" + end + + + def test_handles_missing_content_length + assert_nothing_thrown do + post "/", nil, "CONTENT_LENGTH" => nil + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..e80b553 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,11 @@ +require "rubygems" +require 'bundler/setup' +require 'bundler/gem_tasks' +require "test/unit" + +require "rack" +require "rack/test" + +Test::Unit::TestCase.class_eval do + include Rack::Test::Methods +end