├── .rspec ├── .document ├── lib ├── rack_reverse_proxy │ ├── version.rb │ ├── errors.rb │ ├── middleware.rb │ ├── response_builder.rb │ ├── rule.rb │ └── roundtrip.rb ├── rack │ └── reverse_proxy.rb └── rack_reverse_proxy.rb ├── script └── rubocop ├── .gitignore ├── Rakefile ├── .travis.yml ├── .rubocop.yml ├── Gemfile ├── spec ├── support │ └── http_streaming_response_patch.rb ├── rack_reverse_proxy │ └── response_builder_spec.rb ├── spec_helper.rb └── rack │ └── reverse_proxy_spec.rb ├── LICENSE ├── rack-reverse-proxy.gemspec ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | LICENSE 6 | -------------------------------------------------------------------------------- /lib/rack_reverse_proxy/version.rb: -------------------------------------------------------------------------------- 1 | #:nodoc: 2 | module RackReverseProxy 3 | VERSION = "1.0.0-unreleased".freeze 4 | end 5 | -------------------------------------------------------------------------------- /script/rubocop: -------------------------------------------------------------------------------- 1 | #/usr/bin/env bash 2 | 3 | if ruby -e 'exit(1) unless RUBY_VERSION.to_f >= 2.0'; then 4 | bundle exec rubocop 5 | fi 6 | -------------------------------------------------------------------------------- /lib/rack/reverse_proxy.rb: -------------------------------------------------------------------------------- 1 | require "rack_reverse_proxy" 2 | 3 | # Re-opening Rack module only to define ReverseProxy constant 4 | module Rack 5 | ReverseProxy = RackReverseProxy::Middleware 6 | end 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /spec/examples.txt 10 | /tmp/ 11 | *.bundle 12 | *.so 13 | *.o 14 | *.a 15 | mkmf.log 16 | tests.lock 17 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "rake" 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) do |spec| 7 | spec.pattern = "spec/**/*_spec.rb" 8 | end 9 | 10 | task :default => :spec 11 | -------------------------------------------------------------------------------- /lib/rack_reverse_proxy.rb: -------------------------------------------------------------------------------- 1 | require "rack_reverse_proxy/version" 2 | require "rack_reverse_proxy/errors" 3 | require "rack_reverse_proxy/rule" 4 | require "rack_reverse_proxy/middleware" 5 | 6 | # A Reverse Proxy for Rack 7 | module RackReverseProxy 8 | end 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.2.5 4 | - 2.3.1 5 | - 2.4.1 6 | - jruby-9.0.5.0 7 | - rbx 8 | 9 | before_install: 10 | - gem install bundler 11 | 12 | bundler_args: --without development 13 | 14 | script: 15 | - bundle exec rspec 16 | 17 | matrix: 18 | allow_failures: 19 | - rvm: rbx 20 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Style/StringLiterals: 2 | EnforcedStyle: double_quotes 3 | 4 | Metrics/LineLength: 5 | Max: 100 6 | 7 | #begin ruby 1.8 support 8 | Style/HashSyntax: 9 | EnforcedStyle: hash_rockets 10 | 11 | Style/Lambda: 12 | Enabled: false 13 | 14 | Style/TrailingCommaInLiteral: 15 | EnforcedStyleForMultiline: no_comma 16 | 17 | Style/TrailingCommaInArguments: 18 | EnforcedStyleForMultiline: no_comma 19 | 20 | Style/EachWithObject: 21 | Enabled: false 22 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | ruby_version = RUBY_VERSION.to_f 6 | rubocop_platform = [:ruby_20, :ruby_21, :ruby_22, :ruby_23, :ruby_24] 7 | rubocop_platform = [:ruby_20, :ruby_21] if ruby_version < 2.0 8 | 9 | group :test do 10 | gem "rspec" 11 | gem "rack-test" 12 | gem "webmock" 13 | gem "rubocop", :platform => rubocop_platform 14 | 15 | gem "addressable", "< 2.4" if ruby_version < 1.9 16 | end 17 | 18 | group :development, :test do 19 | gem "simplecov" 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/http_streaming_response_patch.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | ## 3 | # Patch HttpStreamingResponse 4 | # in order to support webmocks and still use rack-proxy 5 | # 6 | # Inspired by @ehlertij commits on sportngin/rack-proxy: 7 | # 616574e452fa731f5427d2ff2aff6823fcf28bde 8 | # d8c377f7485997b229ced23c33cfef87d3fb8693 9 | # 75b446a26ceb519ddc28f38b33309e9a2799074c 10 | # 11 | class HttpStreamingResponse 12 | def each(&block) 13 | response.read_body(&block) 14 | ensure 15 | session.end_request_hacked unless mocking? 16 | end 17 | 18 | protected 19 | 20 | def response 21 | if mocking? 22 | @response ||= session.request(@request) 23 | else 24 | super 25 | end 26 | end 27 | 28 | def mocking? 29 | defined?(WebMock) || defined?(FakeWeb) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/rack_reverse_proxy/errors.rb: -------------------------------------------------------------------------------- 1 | module RackReverseProxy 2 | module Errors 3 | # GenericURI indicates that url is too generic 4 | class GenericURI < RuntimeError 5 | attr_reader :url 6 | 7 | def intialize(url) 8 | @url = url 9 | end 10 | 11 | def to_s 12 | %(Your URL "#{@url}" is too generic. Did you mean "http://#{@url}"?) 13 | end 14 | end 15 | 16 | # AmbiguousMatch indicates that path matched more than one endpoint 17 | class AmbiguousMatch < RuntimeError 18 | attr_reader :path, :matches 19 | 20 | def initialize(path, matches) 21 | @path = path 22 | @matches = matches 23 | end 24 | 25 | def to_s 26 | %(Path "#{path}" matched multiple endpoints: #{formatted_matches}) 27 | end 28 | 29 | private 30 | 31 | def formatted_matches 32 | matches.map(&:to_s).join(", ") 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/rack_reverse_proxy/response_builder_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module RackReverseProxy 4 | RSpec.describe ResponseBuilder do 5 | let(:options) { {} } 6 | let(:uri_opt) { { :uri => "http://example.org/hello/world" } } 7 | let(:request) { double("Request") } 8 | 9 | subject(:response) do 10 | ResponseBuilder.new( 11 | request, 12 | URI(uri_opt[:uri]), 13 | options 14 | ).fetch 15 | end 16 | 17 | it "is a Rack::HttpStreamingResponse" do 18 | expect(response).to be_a(Rack::HttpStreamingResponse) 19 | end 20 | 21 | it "sets up read timeout" do 22 | options[:timeout] = 42 23 | expect(response.read_timeout).to eq(42) 24 | end 25 | 26 | it "sets up ssl when needed" do 27 | uri_opt[:uri] = "https://example.org/hello/world" 28 | expect(response.use_ssl).to eq(true) 29 | end 30 | 31 | it "it is possible to change ssl verify mode" do 32 | mode = OpenSSL::SSL::VERIFY_NONE 33 | options[:verify_mode] = mode 34 | expect(response.verify_mode).to eq(mode) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Jon Swope 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/rack_reverse_proxy/middleware.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | require "net/https" 3 | require "rack-proxy" 4 | require "rack_reverse_proxy/roundtrip" 5 | 6 | module RackReverseProxy 7 | # Rack middleware for handling reverse proxying 8 | class Middleware 9 | include NewRelic::Agent::Instrumentation::ControllerInstrumentation if defined? NewRelic 10 | 11 | DEFAULT_OPTIONS = { 12 | :preserve_host => true, 13 | :stripped_headers => nil, 14 | :x_forwarded_headers => true, 15 | :matching => :all, 16 | :replace_response_host => false 17 | } 18 | 19 | def initialize(app = nil, &b) 20 | @app = app || lambda { |_| [404, [], []] } 21 | @rules = [] 22 | @global_options = DEFAULT_OPTIONS 23 | instance_eval(&b) if block_given? 24 | end 25 | 26 | def call(env) 27 | RoundTrip.new(@app, env, @global_options, @rules).call 28 | end 29 | 30 | private 31 | 32 | def reverse_proxy_options(options) 33 | @global_options = @global_options.merge(options) 34 | end 35 | 36 | def reverse_proxy(rule, url = nil, opts = {}) 37 | if rule.is_a?(String) && url.is_a?(String) && URI(url).class == URI::Generic 38 | raise Errors::GenericURI.new, url 39 | end 40 | @rules << Rule.new(rule, url, opts) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/rack_reverse_proxy/response_builder.rb: -------------------------------------------------------------------------------- 1 | module RackReverseProxy 2 | # ResponseBuilder knows target response building process 3 | class ResponseBuilder 4 | def initialize(target_request, uri, options) 5 | @target_request = target_request 6 | @uri = uri 7 | @options = options 8 | end 9 | 10 | def fetch 11 | setup_response 12 | target_response 13 | end 14 | 15 | private 16 | 17 | def setup_response 18 | set_read_timeout 19 | handle_https 20 | handle_verify_mode 21 | end 22 | 23 | def set_read_timeout 24 | return unless read_timeout? 25 | target_response.read_timeout = options[:timeout] 26 | end 27 | 28 | def read_timeout? 29 | options[:timeout].to_i > 0 30 | end 31 | 32 | def handle_https 33 | return unless https? 34 | target_response.use_ssl = true 35 | end 36 | 37 | def https? 38 | "https" == uri.scheme 39 | end 40 | 41 | def handle_verify_mode 42 | return unless verify_mode? 43 | target_response.verify_mode = options[:verify_mode] 44 | end 45 | 46 | def verify_mode? 47 | options.key?(:verify_mode) 48 | end 49 | 50 | def target_response 51 | @_target_response ||= Rack::HttpStreamingResponse.new( 52 | target_request, 53 | uri.host, 54 | uri.port 55 | ) 56 | end 57 | 58 | attr_reader :target_request, :uri, :options 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /rack-reverse-proxy.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "rack_reverse_proxy/version" 4 | 5 | # rubocop:disable 6 | Gem::Specification.new do |spec| 7 | spec.name = "rack-reverse-proxy" 8 | spec.version = RackReverseProxy::VERSION 9 | 10 | spec.authors = [ 11 | "Jon Swope", 12 | "Ian Ehlert", 13 | "Roman Ernst", 14 | "Oleksii Fedorov" 15 | ] 16 | 17 | spec.email = [ 18 | "jaswope@gmail.com", 19 | "ehlertij@gmail.com", 20 | "rernst@farbenmeer.net", 21 | "waterlink000@gmail.com" 22 | ] 23 | 24 | spec.summary = "A Simple Reverse Proxy for Rack" 25 | spec.description = <= 1.0.0" 39 | spec.add_dependency "rack-proxy", "~> 0.6", ">= 0.6.1" 40 | 41 | spec.add_development_dependency "bundler", "~> 1.7" 42 | spec.add_development_dependency "rake", "~> 10.3" 43 | end 44 | # rubocop:enable 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0 (UNRELEASED) 4 | 5 | - Breaking Change: Never modify Location headers that are only paths without hosts. [John Bachir](https://github.com/jjb) [#46](https://github.com/waterlink/rack-reverse-proxy/pull/46) 6 | - Breaking Change: Previously, the Accept-Encoding header was stripped by default, unless the 7 | `preserve_encoding` option was set to true. Now, no headers are stripped by default, and an array 8 | of headers that should be stripped can be specified with the `stripped_headers` option. 9 | - Breaking Change: Previously, rack-reverse-proxy had the behavior/bug that when reverse_proxy_options 10 | was invoked, all options that weren't set in the invokation would be set to nil. Now, those options will remain set at their default values - [Krzysztof Knapik](https://github.com/knapo) [#37](https://github.com/waterlink/rack-reverse-proxy/pull/37) and [John Bachir](https://github.com/jjb) [#47](https://github.com/waterlink/rack-reverse-proxy/pull/47) 11 | - Breaking Change: Previously, when invoking reverse_proxy_options multiple times, only the 12 | final invocation would have any effect. Now, the invocations will have a commulative effect. 13 | [John Bachir](https://github.com/jjb) [#47](https://github.com/waterlink/rack-reverse-proxy/pull/47) 14 | - Bugfix: Fix rack response body for https redirects [John Bachir](https://github.com/jjb) [#43](https://github.com/waterlink/rack-reverse-proxy/pull/43) 15 | 16 | ## 0.12.0 17 | 18 | - Enhancement: Set "X-Forwarded-Proto" header to the proxying scheme. [Motonobu Kuryu](https://github.com/arc279) [#32](https://github.com/waterlink/rack-reverse-proxy/pull/32) 19 | - Bugfix: Upgrade to a version of rack-proxy with the bug fix for the unclosed network resources. [John Bachir](https://github.com/jjb) [#45](https://github.com/waterlink/rack-reverse-proxy/pull/45) 20 | 21 | ## 0.11.0 22 | 23 | - Breaking Change: Rename option x_forwarded_host option to x_forwarded_headers, as it controls both X-Forwarded-Port and X-Forwarded-Host - [Aurelien Derouineau](https://github.com/aderouineau) [#26](https://github.com/waterlink/rack-reverse-proxy/pull/26) 24 | - Breaking Change: Strip Accept-Encoding header before forwarding request. [Max Gulyaev](https://github.com/maxilev) [#27](https://github.com/waterlink/rack-reverse-proxy/pull/27) 25 | 26 | ## 0.10.0 27 | 28 | - Feature: `options[:verify_mode]` to set SSL verification mode. - [Marv Cool](https://github.com/MrMarvin) [#24](https://github.com/waterlink/rack-reverse-proxy/pull/24) and [#25](https://github.com/waterlink/rack-reverse-proxy/pull/25) 29 | 30 | ## 0.9.1 31 | 32 | - Enhancement: Remove `Status` key from response headers as per Rack protocol (see [rack/lint](https://github.com/rack/rack/blob/master/lib/rack/lint.rb#L639)) - [Jan Raasch](https://github.com/janraasch) [#7](https://github.com/waterlink/rack-reverse-proxy/pull/7) 33 | 34 | ## 0.9.0 35 | 36 | - Bugfix: Timeout option matches the documentation - [Paul Hepworth](https://github.com/peppyheppy) 37 | - Ruby 1.8 compatibility - [anujdas](https://github.com/anujdas) 38 | - Bugfix: Omit port in host header for default ports (80, 443), so that it doesn't break some web servers, like "Apache Coyote" - [Peter Suschlik](https://github.com/splattael) 39 | - Bugfix: Don't drop source request's port in response's location header - [Eric Koslow](https://github.com/ekosz) 40 | - Bugfix: Capitalize headers correctly to prevent duplicate headers when used together with other proxies - [Eric Koslow](https://github.com/ekosz) 41 | - Bugfix: Normalize headers from HttpStreamingResponse in order not to break other middlewares - [Jan Raasch](https://github.com/janraasch) 42 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # Code coverage 2 | require "simplecov" 3 | SimpleCov.start 4 | 5 | require "rack/reverse_proxy" 6 | require "rack/test" 7 | require "webmock/rspec" 8 | 9 | # Patch HttpStreamingResponse to make rack-proxy compatible with webmocks 10 | require "support/http_streaming_response_patch" 11 | 12 | # This file was generated by the `rspec --init` command. Conventionally, all 13 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 14 | # The generated `.rspec` file contains `--require spec_helper` which will cause 15 | # this file to always be loaded, without a need to explicitly require it in any 16 | # files. 17 | # 18 | # Given that it is always loaded, you are encouraged to keep this file as 19 | # light-weight as possible. Requiring heavyweight dependencies from this file 20 | # will add to the boot time of your test suite on EVERY test run, even for an 21 | # individual file that may not need all of that loaded. Instead, consider making 22 | # a separate helper file that requires the additional dependencies and performs 23 | # the additional setup, and require it from the spec files that actually need 24 | # it. 25 | # 26 | # The `.rspec` file also contains a few flags that are not defaults but that 27 | # users commonly want. 28 | # 29 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 30 | RSpec.configure do |config| 31 | # rspec-expectations config goes here. You can use an alternate 32 | # assertion/expectation library such as wrong or the stdlib/minitest 33 | # assertions if you prefer. 34 | config.expect_with :rspec do |expectations| 35 | # This option will default to `true` in RSpec 4. It makes the `description` 36 | # and `failure_message` of custom matchers include text for helper methods 37 | # defined using `chain`, e.g.: 38 | # be_bigger_than(2).and_smaller_than(4).description 39 | # # => "be bigger than 2 and smaller than 4" 40 | # ...rather than: 41 | # # => "be bigger than 2" 42 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 43 | end 44 | 45 | # rspec-mocks config goes here. You can use an alternate test double 46 | # library (such as bogus or mocha) by changing the `mock_with` option here. 47 | config.mock_with :rspec do |mocks| 48 | # Prevents you from mocking or stubbing a method that does not exist on 49 | # a real object. This is generally recommended, and will default to 50 | # `true` in RSpec 4. 51 | mocks.verify_partial_doubles = true 52 | end 53 | 54 | # These two settings work together to allow you to limit a spec run 55 | # to individual examples or groups you care about by tagging them with 56 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples 57 | # get run. 58 | config.filter_run :focus 59 | config.run_all_when_everything_filtered = true 60 | 61 | # Allows RSpec to persist some state between runs in order to support 62 | # the `--only-failures` and `--next-failure` CLI options. We recommend 63 | # you configure your source control system to ignore this file. 64 | config.example_status_persistence_file_path = "spec/examples.txt" 65 | 66 | # Limits the available syntax to the non-monkey patched syntax that is 67 | # recommended. For more details, see: 68 | # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax 69 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 70 | # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching 71 | config.disable_monkey_patching! 72 | 73 | # This setting enables warnings. It's recommended, but in some cases may 74 | # be too noisy due to issues in dependencies. 75 | config.warnings = true 76 | 77 | # Many RSpec users commonly either run the entire suite or an individual 78 | # file, and it's useful to allow more verbose output when running an 79 | # individual spec file. 80 | if config.files_to_run.one? 81 | # Use the documentation formatter for detailed output, 82 | # unless a formatter has already been configured 83 | # (e.g. via a command-line flag). 84 | config.default_formatter = "doc" 85 | end 86 | 87 | # Print the 10 slowest examples and example groups at the 88 | # end of the spec run, to help surface which specs are running 89 | # particularly slow. 90 | config.profile_examples = 10 91 | 92 | # Run specs in random order to surface order dependencies. If you find an 93 | # order dependency and want to debug it, you can fix the order by providing 94 | # the seed, which is printed after each run. 95 | # --seed 1234 96 | config.order = :random 97 | 98 | # Seed global randomization in this process using the `--seed` CLI option. 99 | # Setting this allows you to use `--seed` to deterministically reproduce 100 | # test failures related to randomization by passing the same `--seed` value 101 | # as the one that triggered the failure. 102 | Kernel.srand config.seed 103 | 104 | # User-defined configuration 105 | WebMock.disable_net_connect! 106 | end 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Reverse Proxy for Rack 2 | [![TravisCI](https://secure.travis-ci.org/waterlink/rack-reverse-proxy.svg "Build Status")](http://travis-ci.org/waterlink/rack-reverse-proxy "Build Status") 3 | 4 | This is a simple reverse proxy for Rack that pretty heavily rips off Rack Forwarder. It is not meant for production systems (although it may work), as the webserver fronting your app is generally much better at this sort of thing. 5 | 6 | ## Installation 7 | The gem is available on rubygems. Assuming you have a recent version of Rubygems you should just be able to install it via: 8 | 9 | ``` 10 | gem install rack-reverse-proxy 11 | ``` 12 | 13 | For your Gemfile use: 14 | 15 | ```ruby 16 | gem "rack-reverse-proxy", require: "rack/reverse_proxy" 17 | ``` 18 | 19 | ## Usage 20 | 21 | `Rack::ReverseProxy` should ideally be the very first middleware in your 22 | stack. In a typical use case it is being used to proxy an entirely 23 | different website through your application, so it's unlikely that you will want 24 | any other middleware to modify the requests or responses. The examples below 25 | reflect this. 26 | 27 | 28 | ### Generic Rack app example 29 | 30 | ```ruby 31 | require 'rack/reverse_proxy' 32 | 33 | use Rack::ReverseProxy do 34 | # Set :preserve_host to true globally (default is true already) 35 | reverse_proxy_options preserve_host: true 36 | 37 | # Forward the path /test* to http://example.com/test* 38 | reverse_proxy '/test', 'http://example.com/' 39 | 40 | # Forward the path /foo/* to http://example.com/bar/* 41 | reverse_proxy /^\/foo(\/.*)$/, 'http://example.com/bar$1', username: 'name', password: 'basic_auth_secret' 42 | end 43 | 44 | app = proc do |env| 45 | [ 200, {'Content-Type' => 'text/plain'}, ["b"] ] 46 | end 47 | run app 48 | ``` 49 | 50 | ### Ruby on Rails app example 51 | 52 | This example use `config.middleware.insert(0` to ensure that 53 | `Rack::ReverseProxy` is first in the stack. It is possible that 54 | other code in your app (usually in application.rb, development.rb, or production.rb) 55 | will take over this position in the stack. To ensure 56 | that this is not the case, view the stack by running `rails middleware`. You should see 57 | `Rack::ReverseProxy` at the top. Note that 58 | the middleware stack will likely differ slightly in each environment. All that said, it's a pretty 59 | safe bet to put the below code into application.rb. 60 | 61 | ```ruby 62 | # config/application.rb 63 | config.middleware.insert(0, Rack::ReverseProxy) do 64 | reverse_proxy_options preserve_host: true 65 | reverse_proxy '/wiki', 'http://wiki.example.com/' 66 | end 67 | ``` 68 | 69 | ### Rules 70 | 71 | As seen in the Rack example above, `reverse_proxy` can be invoked multiple times with 72 | different rules, which will be commulatively added. 73 | 74 | Rules can be a regex or a string. If a regex is used, you can use the subcaptures in your forwarding url by denoting them with a `$`. 75 | 76 | Right now if more than one rule matches any given route, it throws an exception for an ambiguous match. This will probably change later. If no match is found, the call is forwarded to your application. 77 | 78 | 79 | ### Options 80 | 81 | `reverse_proxy_options` sets global options for all reverse proxies. Available options are: 82 | 83 | * `:preserve_host` Set to false to omit Host headers 84 | * `:username` username for basic auth 85 | * `:password` password for basic auth 86 | * `:matching` is a global only option, if set to :first the first matched url will be requested (no ambigous error). Default: :all. 87 | * `:timeout` seconds to timout the requests 88 | * `:force_ssl` redirects to ssl version, if not already using it (requires `:replace_response_host`). Default: false. 89 | * `:verify_mode` the `OpenSSL::SSL` verify mode passed to Net::HTTP. Default: `OpenSSL::SSL::VERIFY_PEER`. 90 | * `:x_forwarded_headers` sets up proper `X-Forwarded-*` headers. Default: true. 91 | * `:stripped_headers` Array of headers that should be stripped before forwarding reqeust. Default: nil. 92 | e.g. `stripped_headers: ["Accept-Encoding", "Foo-Bar"]` 93 | 94 | If `reverse_proxy_options` is invoked multiple times, the invocations will have a commulative effect, 95 | only overwritting the values which they specify. Example of how this could be useful: 96 | 97 | ```ruby 98 | config.middleware.insert(0, Rack::ReverseProxy) do 99 | reverse_proxy_options preserve_host: false 100 | if Rails.env.production? or Rails.env.staging? 101 | reverse_proxy_options force_ssl: true, replace_response_host: true 102 | end 103 | reverse_proxy /^\/blog(\/?.*)$/, 'http://blog.example.com/blog$1' 104 | end 105 | ``` 106 | 107 | ## Note on Patches/Pull Requests 108 | * Fork the project. 109 | * Make your feature addition or bug fix. 110 | * Add tests for it. This is important so I don't break it in a 111 | future version unintentionally. 112 | * Commit, do not mess with rakefile, version, or history. 113 | (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) 114 | * Send me a pull request. Bonus points for topic branches. 115 | 116 | ## Contributors 117 | 118 | - Jon Swope, creator 119 | - Oleksii Fedorov, maintainer 120 | -------------------------------------------------------------------------------- /lib/rack_reverse_proxy/rule.rb: -------------------------------------------------------------------------------- 1 | module RackReverseProxy 2 | # Rule understands which urls need to be proxied 3 | class Rule 4 | # FIXME: It needs to be hidden 5 | attr_reader :options 6 | 7 | def initialize(spec, url = nil, options = {}) 8 | @has_custom_url = url.nil? 9 | @url = url 10 | @options = options 11 | @spec = build_matcher(spec) 12 | end 13 | 14 | def proxy?(path, *args) 15 | matches(path, *args).any? 16 | end 17 | 18 | def get_uri(path, env, *args) 19 | Candidate.new( 20 | self, 21 | has_custom_url, 22 | path, 23 | env, 24 | matches(path, *args) 25 | ).build_uri 26 | end 27 | 28 | def transform(path, env, response, request_uri, *args) 29 | Candidate.new( 30 | self, 31 | has_custom_url, 32 | path, 33 | env, 34 | matches(path, *args) 35 | ).transform(response, request_uri) 36 | end 37 | 38 | def to_s 39 | %("#{spec}" => "#{url}") 40 | end 41 | 42 | private 43 | 44 | attr_reader :spec, :url, :has_custom_url 45 | 46 | def matches(path, *args) 47 | Matches.new( 48 | spec, 49 | url, 50 | path, 51 | options[:accept_headers], 52 | has_custom_url, 53 | *args 54 | ) 55 | end 56 | 57 | def build_matcher(spec) 58 | return /^#{spec}/ if spec.is_a?(String) 59 | return spec if spec.respond_to?(:match) 60 | return spec if spec.respond_to?(:call) 61 | raise ArgumentError, "Invalid Rule for reverse_proxy" 62 | end 63 | 64 | # Candidate represents a request being matched 65 | class Candidate 66 | def initialize(rule, has_custom_url, path, env, matches) 67 | @rule = rule 68 | @env = env 69 | @path = path 70 | @has_custom_url = has_custom_url 71 | @matches = matches 72 | 73 | @url = evaluate(matches.custom_url) 74 | end 75 | 76 | def build_uri 77 | return nil unless url 78 | raw_uri 79 | end 80 | 81 | def transform(response, request_uri) 82 | matches.transform(response, request_uri) 83 | end 84 | 85 | private 86 | 87 | attr_reader :rule, :url, :has_custom_url, :path, :env, :matches 88 | 89 | def raw_uri 90 | return substitute_matches if with_substitutions? 91 | return just_uri if has_custom_url 92 | uri_with_path 93 | end 94 | 95 | def just_uri 96 | URI.parse(url) 97 | end 98 | 99 | def uri_with_path 100 | URI.join(url, path) 101 | end 102 | 103 | def evaluate(url) 104 | return unless url 105 | return url.call(env) if lazy?(url) 106 | url.clone 107 | end 108 | 109 | def lazy?(url) 110 | url.respond_to?(:call) 111 | end 112 | 113 | def with_substitutions? 114 | url =~ /\$\d/ 115 | end 116 | 117 | def substitute_matches 118 | URI(matches.substitute(url)) 119 | end 120 | end 121 | 122 | # Matches represents collection of matched objects for Rule 123 | class Matches 124 | # rubocop:disable Metrics/ParameterLists 125 | 126 | # FIXME: eliminate :url, :accept_headers, :has_custom_url 127 | def initialize(spec, url, path, accept_headers, has_custom_url, headers, rackreq, *_) 128 | @spec = spec 129 | @url = url 130 | @path = path 131 | @has_custom_url = has_custom_url 132 | @rackreq = rackreq 133 | 134 | @headers = headers if accept_headers 135 | @spec_arity = spec.method(spec_match_method_name).arity 136 | end 137 | 138 | def any? 139 | found.any? 140 | end 141 | 142 | def custom_url 143 | return url unless has_custom_url 144 | found.map do |match| 145 | match.url(path) 146 | end.first 147 | end 148 | 149 | def substitute(url) 150 | found.each_with_index.inject(url) do |acc, (match, i)| 151 | acc.gsub("$#{i}", match) 152 | end 153 | end 154 | 155 | def transform(response, request_uri) 156 | found.inject(response) do |accumulator, match| 157 | if match.respond_to?(:transform) 158 | match.transform(accumulator, request_uri) 159 | else 160 | accumulator 161 | end 162 | end 163 | end 164 | 165 | private 166 | 167 | attr_reader :spec, :url, :path, :headers, :rackreq, :spec_arity, :has_custom_url 168 | 169 | def found 170 | @_found ||= find_matches 171 | end 172 | 173 | def find_matches 174 | Array( 175 | spec.send(spec_match_method_name, *spec_params) 176 | ) 177 | end 178 | 179 | def spec_params 180 | @_spec_params ||= _spec_params 181 | end 182 | 183 | def _spec_params 184 | [ 185 | path, 186 | headers, 187 | rackreq 188 | ][0...spec_param_count] 189 | end 190 | 191 | def spec_param_count 192 | @_spec_param_count ||= _spec_param_count 193 | end 194 | 195 | def _spec_param_count 196 | return 1 if spec_arity == -1 197 | spec_arity 198 | end 199 | 200 | def spec_match_method_name 201 | return :match if spec.respond_to?(:match) 202 | :call 203 | end 204 | end 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /lib/rack_reverse_proxy/roundtrip.rb: -------------------------------------------------------------------------------- 1 | require "rack_reverse_proxy/response_builder" 2 | 3 | module RackReverseProxy 4 | # FIXME: Enable them and fix issues during refactoring 5 | # rubocop:disable Metrics/ClassLength 6 | 7 | # RoundTrip represents one request-response made by rack-reverse-proxy 8 | # middleware. 9 | class RoundTrip 10 | def initialize(app, env, global_options, rules, response_builder_klass = ResponseBuilder) 11 | @app = app 12 | @env = env 13 | @global_options = global_options 14 | @rules = rules 15 | @response_builder_klass = response_builder_klass 16 | end 17 | 18 | def call 19 | return app.call(env) if rule.nil? 20 | return proxy_with_newrelic if new_relic? 21 | proxy 22 | end 23 | 24 | private 25 | 26 | attr_reader :app, :env, :global_options, :rules, :response_builder_klass 27 | 28 | def new_relic? 29 | global_options[:newrelic_instrumentation] 30 | end 31 | 32 | def proxy_with_newrelic 33 | perform_action_with_newrelic_trace(:name => action_name, :request => source_request) do 34 | proxy 35 | end 36 | end 37 | 38 | def action_name 39 | "#{action_path}/#{source_request.request_method}" 40 | end 41 | 42 | def action_path 43 | # Rack::ReverseProxy/foo/bar#GET 44 | source_request.path.gsub(%r{/\d+}, "/:id").gsub(%r{^/}, "") 45 | end 46 | 47 | def uri 48 | return @_uri if defined?(@_uri) 49 | @_uri = rule.get_uri(path, env, headers, source_request) 50 | end 51 | 52 | def options 53 | @_options ||= global_options.dup.merge(rule.options) 54 | end 55 | 56 | def https_redirect 57 | rewrite_uri(uri, source_request) 58 | uri.scheme = "https" 59 | [301, { "Location" => uri.to_s }, [""]] 60 | end 61 | 62 | def need_https_redirect? 63 | options[:force_ssl] && 64 | options[:replace_response_host] && 65 | source_request.scheme == "http" 66 | end 67 | 68 | def target_request 69 | @_target_request ||= build_target_request 70 | end 71 | 72 | def target_request_headers 73 | @_target_request_headers ||= headers 74 | end 75 | 76 | def build_target_request 77 | Net::HTTP.const_get( 78 | source_request.request_method.capitalize 79 | ).new(uri.request_uri) 80 | end 81 | 82 | def preserve_host 83 | return unless options[:preserve_host] 84 | target_request_headers["HOST"] = host_header 85 | end 86 | 87 | def strip_headers 88 | return unless options[:stripped_headers] 89 | options[:stripped_headers].each do |header| 90 | target_request_headers.delete(header) 91 | end 92 | end 93 | 94 | def host_header 95 | return uri.host if uri.port == uri.default_port 96 | "#{uri.host}:#{uri.port}" 97 | end 98 | 99 | def set_forwarded_headers 100 | return unless options[:x_forwarded_headers] 101 | target_request_headers["X-Forwarded-Host"] = source_request.host 102 | target_request_headers["X-Forwarded-Port"] = source_request.port.to_s 103 | target_request_headers["X-Forwarded-Proto"] = source_request.scheme 104 | end 105 | 106 | def initialize_http_header 107 | target_request.initialize_http_header(target_request_headers) 108 | end 109 | 110 | def set_basic_auth 111 | return unless need_basic_auth? 112 | target_request.basic_auth(options[:username], options[:password]) 113 | end 114 | 115 | def need_basic_auth? 116 | options[:username] && options[:password] 117 | end 118 | 119 | def setup_body 120 | return unless can_have_body? && body? 121 | source_request.body.rewind 122 | target_request.body_stream = source_request.body 123 | end 124 | 125 | def can_have_body? 126 | target_request.request_body_permitted? 127 | end 128 | 129 | def body? 130 | source_request.body 131 | end 132 | 133 | def set_content_length 134 | target_request.content_length = source_request.content_length || 0 135 | end 136 | 137 | def set_content_type 138 | return unless content_type? 139 | target_request.content_type = source_request.content_type 140 | end 141 | 142 | def content_type? 143 | source_request.content_type 144 | end 145 | 146 | def target_response 147 | @_target_response ||= response_builder_klass.new( 148 | target_request, 149 | uri, 150 | options 151 | ).fetch 152 | end 153 | 154 | def response_headers 155 | @_response_headers ||= build_response_headers 156 | end 157 | 158 | def build_response_headers 159 | ["Transfer-Encoding", "Status"].inject(rack_response_headers) do |acc, header| 160 | acc.delete(header) 161 | acc 162 | end 163 | end 164 | 165 | def rack_response_headers 166 | Rack::Utils::HeaderHash.new( 167 | Rack::Proxy.normalize_headers( 168 | format_headers(target_response.headers) 169 | ) 170 | ) 171 | end 172 | 173 | def replace_location_header 174 | return unless need_replace_location? 175 | rewrite_uri(response_location, source_request) 176 | response_headers["Location"] = response_location.to_s 177 | end 178 | 179 | def response_location 180 | @_response_location ||= URI(response_headers["Location"]) 181 | end 182 | 183 | def need_replace_location? 184 | response_headers["Location"] && options[:replace_response_host] && response_location.host 185 | end 186 | 187 | def setup_request 188 | preserve_host 189 | strip_headers 190 | set_forwarded_headers 191 | initialize_http_header 192 | set_basic_auth 193 | setup_body 194 | set_content_length 195 | set_content_type 196 | end 197 | 198 | def setup_response_headers 199 | replace_location_header 200 | end 201 | 202 | def rack_response 203 | [target_response.status, response_headers, target_response.body] 204 | end 205 | 206 | def proxy 207 | return app.call(env) if uri.nil? 208 | return https_redirect if need_https_redirect? 209 | 210 | setup_request 211 | setup_response_headers 212 | 213 | transform_response(rack_response) 214 | end 215 | 216 | def transform_response(response) 217 | rule.transform(path, env, response, uri, headers, source_request) 218 | end 219 | 220 | def format_headers(headers) 221 | headers.inject({}) do |acc, (key, val)| 222 | formated_key = key.split("-").map(&:capitalize).join("-") 223 | acc[formated_key] = Array(val) 224 | acc 225 | end 226 | end 227 | 228 | def request_default_port?(req) 229 | [["http", 80], ["https", 443]].include?([req.scheme, req.port]) 230 | end 231 | 232 | def rewrite_uri(uri, original_req) 233 | uri.scheme = original_req.scheme 234 | uri.host = original_req.host 235 | uri.port = original_req.port unless request_default_port?(original_req) 236 | end 237 | 238 | def source_request 239 | @_source_request ||= Rack::Request.new(env) 240 | end 241 | 242 | def rule 243 | return @_rule if defined?(@_rule) 244 | @_rule = find_rule 245 | end 246 | 247 | def find_rule 248 | return if matches.empty? 249 | non_ambiguous_match 250 | matches.first 251 | end 252 | 253 | def path 254 | @_path ||= source_request.fullpath 255 | end 256 | 257 | def headers 258 | Rack::Proxy.extract_http_request_headers(source_request.env) 259 | end 260 | 261 | def matches 262 | @_matches ||= rules.select do |rule| 263 | rule.proxy?(path, headers, source_request) 264 | end 265 | end 266 | 267 | def non_ambiguous_match 268 | return unless ambiguous_match? 269 | raise Errors::AmbiguousMatch.new(path, matches) 270 | end 271 | 272 | def ambiguous_match? 273 | matches.length > 1 && global_options[:matching] != :first 274 | end 275 | end 276 | end 277 | -------------------------------------------------------------------------------- /spec/rack/reverse_proxy_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "cgi" 3 | require "base64" 4 | 5 | RSpec.describe Rack::ReverseProxy do 6 | include Rack::Test::Methods 7 | 8 | def app 9 | Rack::ReverseProxy.new 10 | end 11 | 12 | def dummy_app 13 | lambda { |_| [200, {}, ["Dummy App"]] } 14 | end 15 | 16 | let(:http_streaming_response) do 17 | double( 18 | "Rack::HttpStreamingResponse", 19 | :use_ssl= => nil, 20 | :verify_mode= => nil, 21 | :headers => {}, 22 | :status => 200, 23 | :body => "OK" 24 | ) 25 | end 26 | 27 | describe "global options", focus: true do 28 | it "starts with default global options" do 29 | m = Rack::ReverseProxy.new(dummy_app) do 30 | reverse_proxy "/test", "http://example.com/" 31 | end 32 | expect(m.instance_variable_get(:@global_options)).to eq(RackReverseProxy::Middleware::DEFAULT_OPTIONS) 33 | end 34 | it "allows options to be set via reverse_proxy_options, maintains global defaults" do 35 | m = Rack::ReverseProxy.new(dummy_app) do 36 | reverse_proxy "/test", "http://example.com/" 37 | reverse_proxy_options preserve_host: "preserve_host_val" 38 | end 39 | expect(m.instance_variable_get(:@global_options)).to_not eq(RackReverseProxy::Middleware::DEFAULT_OPTIONS) 40 | expect(m.instance_variable_get(:@global_options)[:preserve_host]).to eq("preserve_host_val") 41 | raise "necessary condition for test is missing" if RackReverseProxy::Middleware::DEFAULT_OPTIONS[:x_forwarded_headers].nil? 42 | expect(m.instance_variable_get(:@global_options)[:x_forwarded_headers]).to eq(RackReverseProxy::Middleware::DEFAULT_OPTIONS[:x_forwarded_headers]) 43 | end 44 | it "supports multiple commulative invocations of reverse_proxy_options" do 45 | m = Rack::ReverseProxy.new(dummy_app) do 46 | reverse_proxy "/test", "http://example.com/" 47 | reverse_proxy_options preserve_host: "preserve_host_val", stripped_headers: ["foo"] 48 | reverse_proxy_options replace_response_host: "replace_response_host_val", stripped_headers: ["bar"] 49 | end 50 | expect(m.instance_variable_get(:@global_options)[:preserve_host]).to eq("preserve_host_val") 51 | expect(m.instance_variable_get(:@global_options)[:replace_response_host]).to eq("replace_response_host_val") 52 | expect(m.instance_variable_get(:@global_options)[:stripped_headers]).to eq(["bar"]) 53 | end 54 | end 55 | 56 | describe "as middleware" do 57 | def app 58 | Rack::ReverseProxy.new(dummy_app) do 59 | reverse_proxy "/test", "http://example.com/", :preserve_host => true 60 | reverse_proxy "/2test", lambda { |_| "http://example.com/" } 61 | end 62 | end 63 | 64 | it "forwards requests to the calling app when the path is not matched" do 65 | get "/" 66 | expect(last_response.body).to eq("Dummy App") 67 | expect(last_response).to be_ok 68 | end 69 | 70 | it "proxies requests when a pattern is matched" do 71 | stub_request(:get, "http://example.com/test").to_return(:body => "Proxied App") 72 | get "/test" 73 | expect(last_response.body).to eq("Proxied App") 74 | end 75 | 76 | it "produces a response header of type HeaderHash" do 77 | stub_request(:get, "http://example.com/test") 78 | get "/test" 79 | expect(last_response.headers).to be_an_instance_of(Rack::Utils::HeaderHash) 80 | end 81 | 82 | it "parses the headers as a Hash with values of type String" do 83 | stub_request(:get, "http://example.com/test").to_return( 84 | :headers => { "cache-control" => "max-age=300, public" } 85 | ) 86 | get "/test" 87 | expect(last_response.headers["cache-control"]).to be_an_instance_of(String) 88 | expect(last_response.headers["cache-control"]).to eq("max-age=300, public") 89 | end 90 | 91 | it "proxies requests to a lambda url when a pattern is matched" do 92 | stub_request(:get, "http://example.com/2test").to_return(:body => "Proxied App2") 93 | get "/2test" 94 | expect(last_response.body).to eq("Proxied App2") 95 | end 96 | 97 | it "returns headers from proxied app as strings" do 98 | stub_request(:get, "http://example.com/test").to_return( 99 | :body => "Proxied App", 100 | :headers => { "Proxied-Header" => "TestValue" } 101 | ) 102 | get "/test" 103 | expect(last_response.headers["Proxied-Header"]).to eq("TestValue") 104 | end 105 | 106 | it "sets the Host header w/o default port" do 107 | stub_request(:any, "example.com/test/stuff") 108 | get "/test/stuff" 109 | expect( 110 | a_request(:get, "http://example.com/test/stuff").with( 111 | :headers => { "Host" => "example.com" } 112 | ) 113 | ).to have_been_made 114 | end 115 | 116 | it "sets the X-Forwarded-Host header to the proxying host by default" do 117 | stub_request(:any, "example.com/test/stuff") 118 | get "/test/stuff" 119 | expect( 120 | a_request(:get, "http://example.com/test/stuff").with( 121 | :headers => { "X-Forwarded-Host" => "example.org" } 122 | ) 123 | ).to have_been_made 124 | end 125 | 126 | it "sets the X-Forwarded-Port header to the proxying port by default" do 127 | stub_request(:any, "example.com/test/stuff") 128 | get "/test/stuff" 129 | expect( 130 | a_request(:get, "http://example.com/test/stuff").with( 131 | :headers => { "X-Forwarded-Port" => "80" } 132 | ) 133 | ).to have_been_made 134 | end 135 | 136 | it "sets the X-Forwarded-Proto header to the proxying scheme by default" do 137 | stub_request(:any, "example.com/test/stuff") 138 | get "https://example.com/test/stuff" 139 | expect( 140 | a_request(:get, "example.com/test/stuff").with( 141 | :headers => { "X-Forwarded-Proto" => "https" } 142 | ) 143 | ).to have_been_made 144 | end 145 | 146 | it "does not produce headers with a Status key" do 147 | stub_request(:get, "http://example.com/2test").to_return( 148 | :status => 301, :headers => { :status => "301 Moved Permanently" } 149 | ) 150 | 151 | get "/2test" 152 | 153 | headers = last_response.headers.to_hash 154 | expect(headers["Status"]).to be_nil 155 | end 156 | 157 | it "formats the headers correctly to avoid duplicates" do 158 | stub_request(:get, "http://example.com/2test").to_return( 159 | :headers => { :date => "Wed, 22 Jul 2015 11:27:21 GMT" } 160 | ) 161 | 162 | get "/2test" 163 | 164 | headers = last_response.headers.to_hash 165 | expect(headers["Date"]).to eq("Wed, 22 Jul 2015 11:27:21 GMT") 166 | expect(headers["date"]).to be_nil 167 | end 168 | 169 | it "formats the headers with dashes correctly" do 170 | stub_request(:get, "http://example.com/2test").to_return( 171 | :status => 301, 172 | :headers => { :status => "301 Moved Permanently", :"x-additional-info" => "something" } 173 | ) 174 | 175 | get "/2test" 176 | 177 | headers = last_response.headers.to_hash 178 | expect(headers["X-Additional-Info"]).to eq("something") 179 | expect(headers["x-additional-info"]).to be_nil 180 | end 181 | 182 | it "the response header includes content-length" do 183 | body = "this is the test body" 184 | stub_request(:any, "example.com/test/stuff").to_return( 185 | :body => body, :headers => { "Content-Length" => "10" } 186 | ) 187 | get "/test/stuff" 188 | expect(last_response.headers["Content-Length"]).to eq(body.length.to_s) 189 | end 190 | 191 | describe "with non-default port" do 192 | def app 193 | Rack::ReverseProxy.new(dummy_app) do 194 | reverse_proxy "/test", "http://example.com:8080/" 195 | end 196 | end 197 | 198 | it "sets the Host header including non-default port" do 199 | stub_request(:any, "example.com:8080/test/stuff") 200 | get "/test/stuff" 201 | expect( 202 | a_request(:get, "http://example.com:8080/test/stuff").with( 203 | :headers => { "Host" => "example.com:8080" } 204 | ) 205 | ).to have_been_made 206 | end 207 | end 208 | 209 | describe "with preserve host turned off" do 210 | def app 211 | Rack::ReverseProxy.new(dummy_app) do 212 | reverse_proxy "/test", "http://example.com/", :preserve_host => false 213 | end 214 | end 215 | 216 | it "does not set the Host header" do 217 | stub_request(:any, "example.com/test/stuff") 218 | get "/test/stuff" 219 | 220 | expect( 221 | a_request(:get, "http://example.com/test/stuff").with( 222 | :headers => { "Host" => "example.com" } 223 | ) 224 | ).not_to have_been_made 225 | 226 | expect(a_request(:get, "http://example.com/test/stuff")).to have_been_made 227 | end 228 | end 229 | 230 | context "stripped_headers option" do 231 | subject do 232 | stub_request(:any, "http://example.com/test") 233 | get "/test", {}, "HTTP_ACCEPT_ENCODING" => "gzip, deflate", "HTTP_FOO_BAR" => "baz" 234 | end 235 | 236 | describe "with stripped_headers not set" do 237 | def app 238 | Rack::ReverseProxy.new(dummy_app) do 239 | reverse_proxy "/test", "http://example.com/" 240 | end 241 | end 242 | 243 | it "forwards the headers" do 244 | subject 245 | expect( 246 | a_request(:get, "http://example.com/test").with( 247 | :headers => { "Accept-Encoding" => "gzip, deflate", "Foo-Bar" => "baz" } 248 | ) 249 | ).to have_been_made 250 | end 251 | end 252 | 253 | describe "with stripped_headers set" do 254 | before do 255 | @stripped_headers = ["Accept-Encoding", "Foo-Bar"] 256 | def app 257 | # so the value is constant in the closure below 258 | stripped_headers = @stripped_headers 259 | Rack::ReverseProxy.new(dummy_app) do 260 | reverse_proxy "/test", "http://example.com/", :stripped_headers => stripped_headers 261 | end 262 | end 263 | end 264 | 265 | it "removes the stripped headers" do 266 | subject 267 | expect( 268 | a_request(:get, "http://example.com/test").with{ |req| 269 | req.headers.each do |header, value| 270 | if @stripped_headers.include?(header) 271 | fail "expected #{header} to not be present" 272 | end 273 | end 274 | } 275 | ).to have_been_made 276 | end 277 | end 278 | end 279 | 280 | describe "with x_forwarded_headers turned off" do 281 | def app 282 | Rack::ReverseProxy.new(dummy_app) do 283 | reverse_proxy_options :x_forwarded_headers => false 284 | reverse_proxy "/test", "http://example.com/" 285 | end 286 | end 287 | 288 | it "does not set the X-Forwarded-Host header to the proxying host" do 289 | stub_request(:any, "example.com/test/stuff") 290 | get "/test/stuff" 291 | expect( 292 | a_request(:get, "http://example.com/test/stuff").with( 293 | :headers => { "X-Forwarded-Host" => "example.org" } 294 | ) 295 | ).not_to have_been_made 296 | expect(a_request(:get, "http://example.com/test/stuff")).to have_been_made 297 | end 298 | 299 | it "does not set the X-Forwarded-Port header to the proxying port" do 300 | stub_request(:any, "example.com/test/stuff") 301 | get "/test/stuff" 302 | expect( 303 | a_request(:get, "http://example.com/test/stuff").with( 304 | :headers => { "X-Forwarded-Port" => "80" } 305 | ) 306 | ).not_to have_been_made 307 | expect(a_request(:get, "http://example.com/test/stuff")).to have_been_made 308 | end 309 | 310 | it "does not set the X-Forwarded-Proto header to the proxying scheme" do 311 | stub_request(:any, "example.com/test/stuff") 312 | get "https://example.com/test/stuff" 313 | expect( 314 | a_request(:get, "example.com/test/stuff").with( 315 | :headers => { "X-Forwarded-Proto" => "https" } 316 | ) 317 | ).not_to have_been_made 318 | expect(a_request(:get, "example.com/test/stuff")).to have_been_made 319 | end 320 | end 321 | 322 | describe "with timeout configuration" do 323 | def app 324 | Rack::ReverseProxy.new(dummy_app) do 325 | reverse_proxy "/test/slow", "http://example.com/", :timeout => 99 326 | end 327 | end 328 | 329 | it "makes request with basic auth" do 330 | stub_request(:get, "http://example.com/test/slow") 331 | allow(Rack::HttpStreamingResponse).to receive(:new).and_return(http_streaming_response) 332 | expect(http_streaming_response).to receive(:read_timeout=).with(99) 333 | get "/test/slow" 334 | end 335 | end 336 | 337 | describe "without timeout configuration" do 338 | def app 339 | Rack::ReverseProxy.new(dummy_app) do 340 | reverse_proxy "/test/slow", "http://example.com/" 341 | end 342 | end 343 | 344 | it "makes request with basic auth" do 345 | stub_request(:get, "http://example.com/test/slow") 346 | allow(Rack::HttpStreamingResponse).to receive(:new).and_return(http_streaming_response) 347 | expect(http_streaming_response).not_to receive(:read_timeout=) 348 | get "/test/slow" 349 | end 350 | end 351 | 352 | describe "with basic auth turned on" do 353 | def app 354 | Rack::ReverseProxy.new(dummy_app) do 355 | reverse_proxy "/test", "http://example.com/", :username => "joe", :password => "shmoe" 356 | end 357 | end 358 | 359 | it "makes request with basic auth" do 360 | stub_request(:get, "http://example.com/test/stuff").with( 361 | :basic_auth => %w(joe shmoe) 362 | ).to_return( 363 | :body => "secured content" 364 | ) 365 | get "/test/stuff" 366 | expect(last_response.body).to eq("secured content") 367 | end 368 | end 369 | 370 | describe "with replace response host turned on" do 371 | def app 372 | Rack::ReverseProxy.new(dummy_app) do 373 | reverse_proxy "/test", "http://example.com/", :replace_response_host => true 374 | end 375 | end 376 | 377 | it "replaces the location response header" do 378 | stub_request(:get, "http://example.com/test/stuff").to_return( 379 | :headers => { "location" => "http://test.com/bar" } 380 | ) 381 | get "http://example.com/test/stuff" 382 | expect(last_response.headers["location"]).to eq("http://example.com/bar") 383 | end 384 | 385 | it "keeps the port of the location" do 386 | stub_request(:get, "http://example.com/test/stuff").to_return( 387 | :headers => { "location" => "http://test.com/bar" } 388 | ) 389 | get "http://example.com:3000/test/stuff" 390 | expect(last_response.headers["location"]).to eq("http://example.com:3000/bar") 391 | end 392 | 393 | it "doesn't keep the port when it's default for the protocol" do 394 | # webmock doesn't allow to stub an https URI, but this is enough to 395 | # reply to the https code path 396 | stub_request(:get, "http://example.com/test/stuff").to_return( 397 | :headers => { "location" => "http://test.com/bar" } 398 | ) 399 | get "https://example.com/test/stuff" 400 | expect(last_response.headers["location"]).to eq("https://example.com/bar") 401 | end 402 | 403 | it "doesn't replaces the location response header if it has no host" do 404 | stub_request(:get, "http://example.com/test/stuff").to_return( 405 | :headers => { "location" => "/bar" } 406 | ) 407 | get "http://example.com/test/stuff" 408 | expect(last_response.headers["location"]).to eq("/bar") 409 | end 410 | end 411 | 412 | describe "with ambiguous routes and all matching" do 413 | def app 414 | Rack::ReverseProxy.new(dummy_app) do 415 | reverse_proxy_options :matching => :all 416 | reverse_proxy "/test", "http://example.com/" 417 | reverse_proxy(%r{^/test}, "http://example.com/") 418 | end 419 | end 420 | 421 | it "raises an exception" do 422 | expect { get "/test" }.to raise_error(RackReverseProxy::Errors::AmbiguousMatch) 423 | end 424 | end 425 | 426 | # FIXME: descriptions are not consistent with examples 427 | describe "with ambiguous routes and first matching" do 428 | def app 429 | Rack::ReverseProxy.new(dummy_app) do 430 | reverse_proxy_options :matching => :first 431 | reverse_proxy "/test", "http://example1.com/" 432 | reverse_proxy(%r{^/test}, "http://example2.com/") 433 | end 434 | end 435 | 436 | it "raises an exception" do 437 | stub_request(:get, "http://example1.com/test").to_return(:body => "Proxied App") 438 | get "/test" 439 | expect(last_response.body).to eq("Proxied App") 440 | end 441 | end 442 | 443 | describe "with force ssl turned on" do 444 | def app 445 | Rack::ReverseProxy.new(dummy_app) do 446 | reverse_proxy "/test", "http://example1.com/", 447 | :force_ssl => true, :replace_response_host => true 448 | end 449 | end 450 | 451 | it "redirects to the ssl version when requesting non-ssl" do 452 | stub_request(:get, "http://example1.com/test/stuff").to_return(:body => "proxied") 453 | get "http://example.com/test/stuff" 454 | expect(last_response.headers["Location"]).to eq("https://example.com/test/stuff") 455 | end 456 | 457 | it "does nothing when already ssl" do 458 | stub_request(:get, "http://example1.com/test/stuff").to_return(:body => "proxied") 459 | get "https://example.com/test/stuff" 460 | expect(last_response.body).to eq("proxied") 461 | end 462 | end 463 | 464 | describe "with a route as a regular expression" do 465 | def app 466 | Rack::ReverseProxy.new(dummy_app) do 467 | reverse_proxy %r{^/test(/.*)$}, "http://example.com$1" 468 | end 469 | end 470 | 471 | it "supports subcaptures" do 472 | stub_request(:get, "http://example.com/path").to_return(:body => "Proxied App") 473 | get "/test/path" 474 | expect(last_response.body).to eq("Proxied App") 475 | end 476 | end 477 | 478 | describe "with a https route" do 479 | def app 480 | Rack::ReverseProxy.new(dummy_app) do 481 | reverse_proxy "/test", "https://example.com" 482 | end 483 | end 484 | 485 | it "makes a secure request" do 486 | stub_request(:get, "https://example.com/test/stuff").to_return( 487 | :body => "Proxied Secure App" 488 | ) 489 | get "/test/stuff" 490 | expect(last_response.body).to eq("Proxied Secure App") 491 | end 492 | 493 | it "sets the Host header w/o default port" do 494 | stub_request(:any, "https://example.com/test/stuff") 495 | get "/test/stuff" 496 | expect( 497 | a_request(:get, "https://example.com/test/stuff").with( 498 | :headers => { "Host" => "example.com" } 499 | ) 500 | ).to have_been_made 501 | end 502 | end 503 | 504 | describe "with a https route on non-default port" do 505 | def app 506 | Rack::ReverseProxy.new(dummy_app) do 507 | reverse_proxy "/test", "https://example.com:8443" 508 | end 509 | end 510 | 511 | it "sets the Host header including non-default port" do 512 | stub_request(:any, "https://example.com:8443/test/stuff") 513 | get "/test/stuff" 514 | expect( 515 | a_request(:get, "https://example.com:8443/test/stuff").with( 516 | :headers => { "Host" => "example.com:8443" } 517 | ) 518 | ).to have_been_made 519 | end 520 | end 521 | 522 | describe "with a route as a string" do 523 | def app 524 | Rack::ReverseProxy.new(dummy_app) do 525 | reverse_proxy "/test", "http://example.com" 526 | reverse_proxy "/path", "http://example.com/foo$0" 527 | end 528 | end 529 | 530 | it "appends the full path to the uri" do 531 | stub_request(:get, "http://example.com/test/stuff").to_return(:body => "Proxied App") 532 | get "/test/stuff" 533 | expect(last_response.body).to eq("Proxied App") 534 | end 535 | end 536 | 537 | describe "with a generic url" do 538 | def app 539 | Rack::ReverseProxy.new(dummy_app) do 540 | reverse_proxy "/test", "example.com" 541 | end 542 | end 543 | 544 | it "throws an exception" do 545 | expect { app }.to raise_error(RackReverseProxy::Errors::GenericURI) 546 | end 547 | end 548 | 549 | describe "with a matching route" do 550 | def app 551 | Rack::ReverseProxy.new(dummy_app) do 552 | reverse_proxy "/test", "http://example.com/" 553 | end 554 | end 555 | 556 | %w(get head delete put post).each do |method| 557 | describe "and using method #{method}" do 558 | it "forwards the correct request" do 559 | stub_request(method.to_sym, "http://example.com/test").to_return( 560 | :body => "Proxied App for #{method}" 561 | ) 562 | send(method, "/test") 563 | expect(last_response.body).to eq("Proxied App for #{method}") 564 | end 565 | 566 | if %w(put post).include?(method) 567 | it "forwards the request payload" do 568 | stub_request( 569 | method.to_sym, 570 | "http://example.com/test" 571 | ).to_return { |req| { :body => req.body } } 572 | send(method, "/test", :test => "test") 573 | expect(last_response.body).to eq("test=test") 574 | end 575 | end 576 | end 577 | end 578 | end 579 | 580 | describe "with a matching lambda" do 581 | def app 582 | Rack::ReverseProxy.new(dummy_app) do 583 | reverse_proxy lambda { |path| path.match(%r{^/test}) }, "http://lambda.example.org" 584 | end 585 | end 586 | 587 | it "forwards requests to the calling app when path is not matched" do 588 | get "/users" 589 | expect(last_response).to be_ok 590 | expect(last_response.body).to eq("Dummy App") 591 | end 592 | 593 | it "proxies requests when a pattern is matched" do 594 | stub_request(:get, "http://lambda.example.org/test").to_return(:body => "Proxied App") 595 | 596 | get "/test" 597 | expect(last_response.body).to eq("Proxied App") 598 | end 599 | end 600 | 601 | describe "with a matching class" do 602 | #:nodoc: 603 | class Matcher 604 | def self.match(path) 605 | return unless path =~ %r{^/(test|users)} 606 | Matcher.new 607 | end 608 | 609 | def url(path) 610 | return "http://users-example.com" + path if path.include?("user") 611 | "http://example.com" + path 612 | end 613 | end 614 | 615 | def app 616 | Rack::ReverseProxy.new(dummy_app) do 617 | reverse_proxy Matcher 618 | end 619 | end 620 | 621 | it "forwards requests to the calling app when the path is not matched" do 622 | get "/" 623 | expect(last_response.body).to eq("Dummy App") 624 | expect(last_response).to be_ok 625 | end 626 | 627 | it "proxies requests when a pattern is matched" do 628 | stub_request(:get, "http://example.com/test").to_return(:body => "Proxied App") 629 | stub_request(:get, "http://users-example.com/users").to_return(:body => "User App") 630 | 631 | get "/test" 632 | expect(last_response.body).to eq("Proxied App") 633 | 634 | get "/users" 635 | expect(last_response.body).to eq("User App") 636 | end 637 | end 638 | 639 | describe "with a matching and transforming class" do 640 | #:nodoc: 641 | class MatcherAndTransformer 642 | def self.match(_path) 643 | MatcherAndTransformer.new 644 | end 645 | 646 | def url(_path) 647 | "http://example.org/redirecting" 648 | end 649 | 650 | def transform(response, request_uri) 651 | status, headers, body = response 652 | location = headers["Location"] 653 | headers["Location"] = "?url=" + CGI.escape(location) + 654 | "&request_uri=" + CGI.escape(request_uri.to_s) 655 | [status, headers, body] 656 | end 657 | end 658 | 659 | def app 660 | Rack::ReverseProxy.new(dummy_app) do 661 | reverse_proxy MatcherAndTransformer 662 | end 663 | end 664 | 665 | it "transforms the proxied response" do 666 | stub_request(:get, "http://example.org/redirecting").to_return( 667 | :headers => { 668 | "Location" => "http://example.org/target" 669 | } 670 | ) 671 | 672 | get "/" 673 | expect(last_response.headers["Location"]) 674 | .to eq("?url=http%3A%2F%2Fexample.org%2Ftarget" \ 675 | "&request_uri=http%3A%2F%2Fexample.org%2Fredirecting") 676 | end 677 | end 678 | 679 | describe "with a matching class" do 680 | #:nodoc: 681 | class RequestMatcher 682 | attr_accessor :rackreq 683 | 684 | def initialize(rackreq) 685 | self.rackreq = rackreq 686 | end 687 | 688 | def self.match(path, _headers, rackreq) 689 | return nil unless path =~ %r{^/(test|users)} 690 | RequestMatcher.new(rackreq) 691 | end 692 | 693 | def url(path) 694 | return nil unless rackreq.params["user"] == "omer" 695 | "http://users-example.com" + path 696 | end 697 | end 698 | 699 | def app 700 | Rack::ReverseProxy.new(dummy_app) do 701 | reverse_proxy RequestMatcher 702 | end 703 | end 704 | 705 | it "forwards requests to the calling app when the path is not matched" do 706 | get "/" 707 | expect(last_response.body).to eq("Dummy App") 708 | expect(last_response).to be_ok 709 | end 710 | 711 | it "proxies requests when a pattern is matched" do 712 | stub_request(:get, "http://users-example.com/users?user=omer").to_return( 713 | :body => "User App" 714 | ) 715 | 716 | get "/test", :user => "mark" 717 | expect(last_response.body).to eq("Dummy App") 718 | 719 | get "/users", :user => "omer" 720 | expect(last_response.body).to eq("User App") 721 | end 722 | end 723 | 724 | describe "with a matching class that accepts headers" do 725 | #:nodoc: 726 | class MatcherHeaders 727 | def self.match(path, headers) 728 | MatcherHeaders.new if path.match(%r{^/test}) && 729 | headers["ACCEPT"] && 730 | headers["ACCEPT"] == "foo.bar" 731 | end 732 | 733 | def url(path) 734 | "http://example.com" + path 735 | end 736 | end 737 | 738 | def app 739 | Rack::ReverseProxy.new(dummy_app) do 740 | reverse_proxy MatcherHeaders, nil, :accept_headers => true 741 | end 742 | end 743 | 744 | it "proxies requests when a pattern is matched and correct headers are passed" do 745 | stub_request(:get, "http://example.com/test").to_return( 746 | :body => "Proxied App with Headers" 747 | ) 748 | get "/test", {}, "HTTP_ACCEPT" => "foo.bar" 749 | expect(last_response.body).to eq("Proxied App with Headers") 750 | end 751 | 752 | it "does not proxy requests when a pattern is matched and incorrect headers are passed" do 753 | stub_request(:get, "http://example.com/test").to_return( 754 | :body => "Proxied App with Headers" 755 | ) 756 | get "/test", {}, "HTTP_ACCEPT" => "bar.foo" 757 | expect(last_response.body).not_to eq("Proxied App with Headers") 758 | end 759 | end 760 | end 761 | 762 | describe "as a rack app" do 763 | it "responds with 404 when the path is not matched" do 764 | get "/" 765 | expect(last_response).to be_not_found 766 | end 767 | end 768 | end 769 | --------------------------------------------------------------------------------