From a803b2684bad5fba4f00ccffb1378f7bca40c26d Mon Sep 17 00:00:00 2001 From: Praveen Arimbrathodiyil Date: Thu, 6 Apr 2017 13:02:53 +0530 Subject: [PATCH] Import Upstream version 0.6.1 --- .gitignore | 3 + .travis.yml | 19 ++++ Gemfile | 6 ++ Gemfile.lock | 28 ++++++ LICENSE | 21 +++++ README.md | 96 ++++++++++++++++++++ Rakefile | 14 +++ lib/net_http_hacked.rb | 90 +++++++++++++++++++ lib/rack-proxy.rb | 1 + lib/rack/http_streaming_response.rb | 77 ++++++++++++++++ lib/rack/proxy.rb | 129 +++++++++++++++++++++++++++ rack-proxy.gemspec | 25 ++++++ test/http_streaming_response_test.rb | 47 ++++++++++ test/net_http_hacked_test.rb | 36 ++++++++ test/rack_proxy_test.rb | 118 ++++++++++++++++++++++++ test/test_helper.rb | 11 +++ 16 files changed, 721 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100644 lib/net_http_hacked.rb create mode 100644 lib/rack-proxy.rb create mode 100644 lib/rack/http_streaming_response.rb create mode 100644 lib/rack/proxy.rb create mode 100644 rack-proxy.gemspec create mode 100644 test/http_streaming_response_test.rb create mode 100644 test/net_http_hacked_test.rb create mode 100644 test/rack_proxy_test.rb create mode 100644 test/test_helper.rb 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