├── Rakefile ├── CHANGELOG.md ├── lib └── rack │ ├── streaming_proxy │ ├── version.rb │ ├── errors.rb │ ├── railtie.rb │ ├── request.rb │ ├── response.rb │ ├── proxy.rb │ └── session.rb │ └── streaming_proxy.rb ├── Gemfile ├── .gitignore ├── spec ├── spec_helper.rb ├── proxy.ru ├── app.ru └── streaming_proxy_spec.rb ├── dev ├── proxy.ru ├── client.rb └── streamer.ru ├── LICENSE.txt ├── rack-streaming-proxy.gemspec ├── README.txt └── README.md /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.0.2 (2017-01-25) 2 | 3 | Fixes: 4 | 5 | * Fix undefined method bytesize in rack > 2.0 6 | -------------------------------------------------------------------------------- /lib/rack/streaming_proxy/version.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module StreamingProxy 3 | VERSION = "2.0.2" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in rack-streaming_proxy.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/rack/streaming_proxy.rb: -------------------------------------------------------------------------------- 1 | require 'rack/streaming_proxy/version' 2 | require 'rack/streaming_proxy/proxy' 3 | require 'rack/streaming_proxy/railtie' if defined? ::Rails::Railtie 4 | -------------------------------------------------------------------------------- /lib/rack/streaming_proxy/errors.rb: -------------------------------------------------------------------------------- 1 | module Rack::StreamingProxy 2 | class Error < RuntimeError; end 3 | class UnknownError < Error; end 4 | class HttpServerError < Error; end 5 | end -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path( File.join(File.dirname(__FILE__), %w[.. lib rack streaming_proxy])) 2 | 3 | require "rack/test" 4 | 5 | RSpec.configure do |config| 6 | config.treat_symbols_as_metadata_keys_with_true_values = true 7 | config.run_all_when_everything_filtered = true 8 | config.filter_run :focus 9 | end 10 | -------------------------------------------------------------------------------- /spec/proxy.ru: -------------------------------------------------------------------------------- 1 | require File.expand_path( 2 | File.join(File.dirname(__FILE__), %w[.. lib rack streaming_proxy])) 3 | 4 | ENV['RACK_ENV'] = 'none' # 'development' automatically use Rack::Lint and results in errors with unicorn 5 | # use Rack::CommonLogger 6 | use Rack::StreamingProxy::Proxy do |req| 7 | "http://localhost:4321#{req.path}" 8 | end 9 | run lambda { |env| [200, {}, ["should never get here..."]]} 10 | -------------------------------------------------------------------------------- /dev/proxy.ru: -------------------------------------------------------------------------------- 1 | require File.expand_path( 2 | File.join(File.dirname(__FILE__), %w[.. lib rack streaming_proxy])) 3 | 4 | use Rack::Reloader, 1 5 | # use Rack::CommonLogger # rackup already has commonlogger loaded 6 | use Rack::Lint 7 | use Rack::StreamingProxy do |req| 8 | url = "http://localhost:4000#{req.path}" 9 | url << "?#{req.query_string}" unless req.query_string.empty? 10 | url 11 | end 12 | 13 | run lambda { |env| [200, {"Content-Type" => "text/plain"}, ""] } 14 | -------------------------------------------------------------------------------- /dev/client.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # coding: utf-8 3 | # cf. https://gist.github.com/sonots/7751554 4 | 5 | require 'net/http' 6 | require 'uri' 7 | 8 | # unicorn spec/app.ru -p 4321 9 | # unicorn spec/proxy.ru -p 4322 10 | PORT = ARGV[0] || 8080 11 | 12 | http = Net::HTTP.new "localhost", PORT 13 | request = Net::HTTP::Get.new "/slow_stream" 14 | #request['Transfer-Encoding'] = 'chunked' 15 | request['Connection'] = 'keep-alive' 16 | http.request(request){|response| 17 | puts "content-length: #{response.content_length}" 18 | body = [] 19 | response.read_body{|x| 20 | body << Time.now 21 | puts "read_block: #{body.length}, #{x.size}byte(s)" 22 | } 23 | puts body 24 | } 25 | -------------------------------------------------------------------------------- /lib/rack/streaming_proxy/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'rails/railtie' 2 | 3 | class Rack::StreamingProxy::Railtie < Rails::Railtie 4 | 5 | config.streaming_proxy = ActiveSupport::OrderedOptions.new 6 | 7 | config.after_initialize do 8 | options = config.streaming_proxy 9 | Rack::StreamingProxy::Proxy.logger = options.logger if options.logger 10 | Rack::StreamingProxy::Proxy.log_verbosity = options.log_verbosity if options.log_verbosity 11 | Rack::StreamingProxy::Proxy.num_retries_on_5xx = options.num_retries_on_5xx if options.num_retries_on_5xx 12 | Rack::StreamingProxy::Proxy.raise_on_5xx = options.raise_on_5xx if options.raise_on_5xx 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2013 Fred Ngo, Nathan Witmer 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /dev/streamer.ru: -------------------------------------------------------------------------------- 1 | class Streamer 2 | include Rack::Utils 3 | 4 | def call(env) 5 | req = Rack::Request.new(env) 6 | headers = {"Content-Type" => "text/plain"} 7 | 8 | @chunked = req.path.start_with?("/chunked") 9 | 10 | if count = req.path.match(/(\d+)$/) 11 | count = count[0].to_i 12 | else 13 | count = 100 14 | end 15 | @strings = count.times.collect {|n| "~~~~~ #{n} ~~~~~\n" } 16 | 17 | if chunked? 18 | headers["Transfer-Encoding"] = "chunked" 19 | else 20 | headers["Content-Length"] = @strings.inject(0) {|sum, s| sum += bytesize(s)}.to_s 21 | end 22 | 23 | [200, headers, self.dup] 24 | end 25 | 26 | def each 27 | term = "\r\n" 28 | @strings.each do |chunk| 29 | if chunked? 30 | size = bytesize(chunk) 31 | yield [size.to_s(16), term, chunk, term].join 32 | else 33 | yield chunk 34 | end 35 | sleep 0.05 36 | end 37 | yield ["0", term, "", term].join if chunked? 38 | end 39 | 40 | protected 41 | 42 | def chunked? 43 | @chunked 44 | end 45 | end 46 | 47 | # use Rack::CommonLogger # rackup already has commonlogger loaded 48 | use Rack::Lint 49 | 50 | # GET / 51 | # GET /10 52 | # GET /chunked 53 | # GET /chunked/10 54 | run Streamer.new 55 | -------------------------------------------------------------------------------- /spec/app.ru: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | class Streamer 4 | include Rack::Utils 5 | 6 | def initialize(sleep=0.05) 7 | @sleep = sleep 8 | @strings = 5.times.collect {|n| "~~~~~ #{n} ~~~~~\n" } 9 | end 10 | 11 | def call(env) 12 | req = Rack::Request.new(env) 13 | headers = {"Content-Type" => "text/plain"} 14 | headers["Transfer-Encoding"] = "chunked" 15 | [200, headers, self.dup] 16 | end 17 | 18 | def each 19 | term = "\r\n" 20 | @strings.each do |chunk| 21 | size = chunk.bytesize 22 | yield [size.to_s(16), term, chunk, term].join 23 | sleep @sleep 24 | end 25 | yield ["0", term, "", term].join 26 | end 27 | end 28 | 29 | # if no content-length is provided and the response isn't streamed, 30 | # make sure the headers get a content length. 31 | use Rack::ContentLength 32 | 33 | map "/" do 34 | run lambda { |env| [200, {"Content-Type" => "text/plain"}, ["ALL GOOD"]] } 35 | end 36 | 37 | map "/stream" do 38 | run Streamer.new 39 | end 40 | 41 | map "/slow_stream" do 42 | run Streamer.new(0.5) 43 | end 44 | 45 | map "/env" do 46 | run lambda { |env| 47 | req = Rack::Request.new(env) 48 | req.POST # modifies env inplace to include "rack.request.form_vars" key 49 | [200, {"Content-Type" => "application/x-yaml"}, [env.to_yaml]] } 50 | end 51 | 52 | map "/boom" do 53 | run lambda { |env| [500, {"Content-Type" => "text/plain"}, ["kaboom!"]] } 54 | end 55 | 56 | -------------------------------------------------------------------------------- /rack-streaming-proxy.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'rack/streaming_proxy/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'rack-streaming-proxy' 8 | spec.version = Rack::StreamingProxy::VERSION 9 | spec.authors = ['Fred Ngo', 'Nathan Witmer', 'Naotoshi Seo'] 10 | spec.email = ['fredngo@gmail.com', 'nwitmer@gmail.com', 'sonots@gmail.com'] 11 | spec.description = %q{Streaming proxy for Rack, the rainbows to Rack::Proxy's unicorn.} 12 | spec.summary = %q{Streaming proxy for Rack, the rainbows to Rack::Proxy's unicorn.} 13 | spec.homepage = 'http://github.com/zerowidth/rack-streaming-proxy' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_runtime_dependency 'rack', '>= 1.4' 22 | spec.add_runtime_dependency 'servolux', '~> 0.10' 23 | 24 | # test 25 | spec.add_development_dependency 'bundler', '>= 1.3' 26 | spec.add_development_dependency 'rake', '>= 10.0' 27 | spec.add_development_dependency 'rspec' 28 | spec.add_development_dependency 'rack-test' 29 | spec.add_development_dependency 'unicorn' 30 | 31 | # debug 32 | spec.add_development_dependency 'pry' 33 | spec.add_development_dependency 'pry-nav' 34 | end 35 | -------------------------------------------------------------------------------- /lib/rack/streaming_proxy/request.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | require 'net/https' 3 | 4 | class Rack::StreamingProxy::Request 5 | 6 | attr_reader :http_request 7 | 8 | def initialize(destination_uri, current_request) 9 | @destination_uri = URI.parse(destination_uri) 10 | @http_request = translate_request(current_request, @destination_uri) 11 | end 12 | 13 | def host 14 | @destination_uri.host 15 | end 16 | 17 | def port 18 | @destination_uri.port 19 | end 20 | 21 | def use_ssl? 22 | @destination_uri.is_a? URI::HTTPS 23 | end 24 | 25 | def uri 26 | @destination_uri.to_s 27 | end 28 | 29 | private 30 | 31 | def translate_request(current_request, uri) 32 | method = current_request.request_method.downcase 33 | method[0..0] = method[0..0].upcase 34 | 35 | request = Net::HTTP.const_get(method).new("#{uri.path}#{"?" if uri.query}#{uri.query}") 36 | 37 | if request.request_body_permitted? and current_request.body 38 | request.body_stream = current_request.body 39 | request.content_length = current_request.content_length if current_request.content_length 40 | request.content_type = current_request.content_type if current_request.content_type 41 | end 42 | 43 | log_headers :debug, 'Current Request Headers', current_request.env 44 | 45 | current_headers = current_request.env.reject { |key, value| !(key.match /^HTTP_/) } 46 | current_headers.each do |key, value| 47 | fixed_name = key.sub(/^HTTP_/, '').gsub('_', '-') 48 | request[fixed_name] = value unless fixed_name.downcase == 'host' 49 | end 50 | request['X-Forwarded-For'] = (current_request.env['X-Forwarded-For'].to_s.split(/, +/) + [current_request.env['REMOTE_ADDR']]).join(', ') 51 | 52 | log_headers :debug, 'Proxy Request Headers:', request 53 | 54 | request 55 | end 56 | 57 | def log_headers(level, title, headers) 58 | Rack::StreamingProxy::Proxy.log level, "+-------------------------------------------------------------" 59 | Rack::StreamingProxy::Proxy.log level, "| #{title}" 60 | Rack::StreamingProxy::Proxy.log level, "+-------------------------------------------------------------" 61 | headers.each { |key, value| Rack::StreamingProxy::Proxy.log level, "| #{key} = #{value.to_s}" } 62 | Rack::StreamingProxy::Proxy.log level, "+-------------------------------------------------------------" 63 | end 64 | 65 | end 66 | -------------------------------------------------------------------------------- /lib/rack/streaming_proxy/response.rb: -------------------------------------------------------------------------------- 1 | require 'rack/streaming_proxy/errors' 2 | 3 | class Rack::StreamingProxy::Response 4 | include Rack::Utils # For HeaderHash 5 | 6 | attr_reader :status, :headers 7 | attr_accessor :client_http_version 8 | 9 | def initialize(piper) 10 | @piper = piper 11 | @client_http_version = '1.1' 12 | receive 13 | end 14 | 15 | # This method is called by Rack itself, to iterate over the proxied contents. 16 | def each 17 | if @body_permitted 18 | term = "\r\n" 19 | 20 | while chunk = read_from_destination 21 | break if chunk == :done 22 | if @chunked 23 | size = chunk.bytesize 24 | next if size == 0 25 | if @client_http_version >= '1.1' 26 | yield [size.to_s(16), term, chunk, term].join 27 | else 28 | yield chunk 29 | end 30 | else 31 | yield chunk 32 | end 33 | end 34 | 35 | finish 36 | 37 | if @chunked && @client_http_version >= '1.1' 38 | yield ['0', term, '', term].join 39 | end 40 | end 41 | end 42 | 43 | private 44 | 45 | def receive 46 | # The first item received from the child will either be an HTTP status code or an Exception. 47 | @status = read_from_destination 48 | 49 | if @status.nil? # This should never happen 50 | Rack::StreamingProxy::Proxy.log :error, "Parent received unexpected nil status!" 51 | finish 52 | raise Rack::StreamingProxy::UnknownError 53 | elsif @status.kind_of? Exception 54 | e = @status 55 | Rack::StreamingProxy::Proxy.log :error, "Parent received an Exception from Child: #{e.class}: #{e.message}" 56 | finish 57 | raise e 58 | end 59 | 60 | Rack::StreamingProxy::Proxy.log :debug, "Parent received: Status = #{@status}." 61 | @body_permitted = read_from_destination 62 | Rack::StreamingProxy::Proxy.log :debug, "Parent received: Reponse has body? = #{@body_permitted}." 63 | @headers = HeaderHash.new(read_from_destination) 64 | @chunked = (@headers['Transfer-Encoding'] == 'chunked') 65 | finish unless @body_permitted # If there is a body, finish will be called inside each. 66 | end 67 | 68 | # parent needs to wait for the child, or it results in the child process becoming defunct, resulting in zombie processes! 69 | # This is very important. See: http://siliconisland.ca/2013/04/26/beware-of-the-zombie-process-apocalypse/ 70 | def finish 71 | Rack::StreamingProxy::Proxy.log :info, "Parent process #{Process.pid} waiting for child process #{@piper.pid} to exit." 72 | @piper.wait 73 | end 74 | 75 | def read_from_destination 76 | @piper.gets 77 | end 78 | 79 | end 80 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | rack-streaming-proxy 2 | by Nathan Witmer 3 | http://github.com/zerowidth/rack-streaming-proxy 4 | 5 | == DESCRIPTION: 6 | 7 | Streaming proxy for Rack, the rainbows to Rack::Proxy's unicorn. 8 | 9 | == FEATURES/PROBLEMS: 10 | 11 | Provides a transparent streaming proxy to be used as rack middleware. 12 | 13 | * Streams the response from the downstream server to minimize memory usage 14 | * Handles chunked encoding if used 15 | * Proxies GET/PUT/POST/DELETE, XHR, and cookies 16 | 17 | Use this when you need to have the response streamed back to the client, 18 | for example when handling large file requests that could be proxied 19 | directly but need to be authenticated against the rest of your middleware 20 | stack. 21 | 22 | Note that this will not work well with EventMachine. EM buffers the entire 23 | rack response before sending it to the client. When testing, try 24 | mongrel (via rackup) or passenger, rather than the EM-based thin. See 25 | http://groups.google.com/group/thin-ruby/browse_thread/thread/4762f8f851b965f6 26 | for more discussion. 27 | 28 | I've included a simple streamer app for testing and development. 29 | 30 | Thanks to: 31 | 32 | * Tom Lea (cwninja) for Rack::Proxy (http://gist.github.com/207938) 33 | * Tim Pease for bones, servolux, &c 34 | 35 | == SYNOPSIS: 36 | 37 | require "rack/streaming_proxy" 38 | 39 | use Rack::StreamingProxy do |request| 40 | # inside the request block, return the full URI to redirect the request to, 41 | # or nil/false if the request should continue on down the middleware stack. 42 | if request.path.start_with?("/proxy") 43 | "http://another_server#{request.path}" 44 | end 45 | end 46 | 47 | == REQUIREMENTS: 48 | 49 | * servolux (gem install servolux) 50 | 51 | == INSTALL: 52 | 53 | * sudo gem install rack-streaming-proxy --source http://gemcutter.org 54 | 55 | == LICENSE: 56 | 57 | (The MIT License) 58 | 59 | Copyright (c) 2009 Nathan Witmer 60 | 61 | Permission is hereby granted, free of charge, to any person obtaining 62 | a copy of this software and associated documentation files (the 63 | 'Software'), to deal in the Software without restriction, including 64 | without limitation the rights to use, copy, modify, merge, publish, 65 | distribute, sublicense, and/or sell copies of the Software, and to 66 | permit persons to whom the Software is furnished to do so, subject to 67 | the following conditions: 68 | 69 | The above copyright notice and this permission notice shall be 70 | included in all copies or substantial portions of the Software. 71 | 72 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 73 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 74 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 75 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 76 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 77 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 78 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rack::StreamingProxy 2 | 3 | A transparent streaming proxy to be used as rack middleware. 4 | 5 | * Streams the response from the downstream server to minimize memory usage 6 | * Handles chunked encoding if used 7 | * Proxies GET/PUT/POST/DELETE, XHR, and cookies 8 | 9 | Now updated to be compatible with Rails 3 and 4, and fixes major concurrency issues that were present in 1.0. 10 | 11 | Use Rack::StreamingProxy when you need to have the response streamed back to the client, for example when handling large file requests that could be proxied directly but need to be authenticated against the rest of your middleware stack. 12 | 13 | Note that this will not work well with EventMachine. EM buffers the entire rack response before sending it to the client. When testing, try Unicorn or Passenger rather than the EM-based Thin (See [discussion](http://groups.google.com/group/thin-ruby/browse_thread/thread/4762f8f851b965f6)). 14 | 15 | A simple streamer app has been included for testing and development. 16 | 17 | ## Usage 18 | 19 | To use inside a Rails app, add a `config/initializers/streaming_proxy.rb` initialization file, and place in it: 20 | 21 | ```ruby 22 | require 'rack/streaming_proxy' 23 | 24 | YourRailsApp::Application.configure do 25 | config.streaming_proxy.logger = Rails.logger # stdout by default 26 | config.streaming_proxy.log_verbosity = Rails.env.production? ? :low : :high # :low or :high, :low by default 27 | config.streaming_proxy.num_retries_on_5xx = 5 # 0 by default 28 | config.streaming_proxy.raise_on_5xx = true # false by default 29 | 30 | # Will be inserted at the end of the middleware stack by default. 31 | config.middleware.use Rack::StreamingProxy::Proxy do |request| 32 | 33 | # Inside the request block, return the full URI to redirect the request to, 34 | # or nil/false if the request should continue on down the middleware stack. 35 | if request.path.start_with?('/search') 36 | "http://www.some-other-service.com/search?#{request.query}" 37 | end 38 | end 39 | end 40 | ``` 41 | 42 | To use as a Rack app: 43 | 44 | ```ruby 45 | require 'rack/streaming_proxy' 46 | 47 | use Rack::StreamingProxy::Proxy do |request| 48 | if request.path.start_with?('/proxy') 49 | # You probably want to get rid of the '/proxy' in the path, when requesting from the destination. 50 | proxy_path = request.path.sub %r{^/proxy}, '' 51 | "http://www.another-server.com#{proxy_path}" 52 | end 53 | end 54 | ``` 55 | 56 | ## Installation 57 | 58 | Add this line to your application's Gemfile: 59 | 60 | gem 'rack-streaming-proxy' 61 | 62 | And then execute: 63 | 64 | $ bundle 65 | 66 | Or install it yourself as: 67 | 68 | $ gem install rack-streaming-proxy 69 | 70 | ## Requirements 71 | 72 | * Ruby = 1.9.3 73 | * rack >= 1.4 74 | * servolux ~> 0.10 75 | 76 | These requirements (other than Ruby) will be automatically installed via Bundler. 77 | 78 | This gem has not been tested with versions lower than those indicated. 79 | 80 | This gem works with Ubuntu 10.04. It has not been tested with later versions of Ubuntu or other Linuxes, but it should work just fine. It has not been tested with OS X but should work as well. However, I doubt it will work on any version of Windows, as it does process-based stuff. You are most welcome to try it and report back. 81 | 82 | ## Contributing 83 | 84 | 1. Fork it 85 | 2. Create your feature branch (`git checkout -b my-new-feature`) 86 | 3. Implement your changes, and make sure to add tests! 87 | 4. Commit your changes (`git commit -am 'Add some feature'`) 88 | 5. Push to the branch (`git push origin my-new-feature`) 89 | 6. Create new Pull Request 90 | 91 | ## Thanks To 92 | 93 | * [Nathan Witmer](http://github.com/zerowidth) for the 1.0 implementation of [Rack::StreamingProxy](http://github.com/zerowidth/rack-streaming-proxy) 94 | * [Tom Lea](http://github.com/cwninja) for [Rack::Proxy](http://gist.github.com/207938), which inspired Rack::StreamingProxy. 95 | * [Tim Pease](http://github.com/TwP) for [Servolux](https://github.com/Twp/servolux) 96 | -------------------------------------------------------------------------------- /lib/rack/streaming_proxy/proxy.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | require 'logger' 3 | require 'rack/streaming_proxy/session' 4 | require 'rack/streaming_proxy/request' 5 | require 'rack/streaming_proxy/response' 6 | 7 | class Rack::StreamingProxy::Proxy 8 | 9 | class << self 10 | attr_accessor :logger, :log_verbosity, :num_retries_on_5xx, :raise_on_5xx 11 | 12 | def set_default_configuration 13 | # Logs to stdout by default unless configured with another logger via Railtie. 14 | @logger ||= Logger.new(STDOUT) 15 | 16 | # At :low verbosity by default -- will not output :debug level messages. 17 | # :high verbosity outputs :debug level messages. 18 | # This is independent of the Logger's log_level, as set in Rails, for example, 19 | # although the Logger's level can override this setting. 20 | @log_verbosity ||= :low 21 | 22 | # No retries are performed by default. 23 | @num_retries_on_5xx ||= 0 24 | 25 | # If the proxy cannot recover from 5xx's through retries (see num_retries_on_5xx), 26 | # then it by default passes through the content from the destination 27 | # e.g. the Apache error page. If you want an exception to be raised instead so 28 | # you can handle it yourself (i.e. display your own error page), set raise_on_5xx to true. 29 | @raise_on_5xx ||= false 30 | end 31 | 32 | def log(level, message) 33 | unless log_verbosity == :low && level == :debug 34 | @logger.send level, "[Rack::StreamingProxy] #{message}" 35 | end 36 | end 37 | 38 | end 39 | 40 | # The block provided to the initializer is given a Rack::Request 41 | # and should return: 42 | # 43 | # * nil/false to skip the proxy and continue down the stack 44 | # * a complete uri (with query string if applicable) to proxy to 45 | # 46 | # Example: 47 | # 48 | # use Rack::StreamingProxy::Proxy do |req| 49 | # if req.path.start_with?('/search') 50 | # "http://some_other_service/search?#{req.query}" 51 | # end 52 | # end 53 | # 54 | # Most headers, request body, and HTTP method are preserved. 55 | # 56 | def initialize(app, &block) 57 | self.class.set_default_configuration 58 | @app = app 59 | @block = block 60 | end 61 | 62 | def call(env) 63 | current_request = Rack::Request.new(env) 64 | 65 | # Decide whether this request should be proxied. 66 | if destination_uri = @block.call(current_request) 67 | self.class.log :info, "Starting proxy request to: #{destination_uri}" 68 | 69 | request = Rack::StreamingProxy::Request.new(destination_uri, current_request) 70 | begin 71 | response = Rack::StreamingProxy::Session.new(request).start 72 | rescue Exception => e # Rescuing only for the purpose of logging to rack.errors 73 | log_rack_error(env, e) 74 | raise e 75 | end 76 | 77 | # Notify client http version to the instance of Response class. 78 | response.client_http_version = env['HTTP_VERSION'].sub(/HTTP\//, '') if env.has_key?('HTTP_VERSION') 79 | # Ideally, both a Content-Length header field and a Transfer-Encoding 80 | # header field are not expected to be present from servers which 81 | # are compliant with RFC2616. However, irresponsible servers may send 82 | # both to rack-streaming-proxy. 83 | # RFC2616 says if a message is received with both a Transfer-Encoding 84 | # header field and a Content-Length header field, the latter MUST be 85 | # ignored. So I deleted a Content-Length header here. 86 | # 87 | # Though there is a case that rack-streaming-proxy deletes both a 88 | # Content-Length and a Transfer-Encoding, a client can acknowledge the 89 | # end of body by closing the connection when the entire response has 90 | # been sent without a Content-Length header. So a Content-Length header 91 | # does not have to be required here in our understaing. 92 | response.headers.delete('Content-Length') if response.headers.has_key?('Transfer-Encoding') 93 | if env.has_key?('HTTP_VERSION') && env['HTTP_VERSION'] < 'HTTP/1.1' 94 | # Be compliant with RFC2146 95 | response.headers.delete('Transfer-Encoding') 96 | end 97 | 98 | self.class.log :info, "Finishing proxy request to: #{destination_uri}" 99 | [response.status, response.headers, response] 100 | 101 | # Continue down the middleware stack if the request is not to be proxied. 102 | else 103 | @app.call(env) 104 | end 105 | end 106 | 107 | private 108 | 109 | def log_rack_error(env, e) 110 | env['rack.errors'].puts e.message 111 | env['rack.errors'].puts e.backtrace #.collect { |line| "\t" + line } 112 | env['rack.errors'].flush 113 | end 114 | 115 | end 116 | -------------------------------------------------------------------------------- /lib/rack/streaming_proxy/session.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | require 'net/https' 3 | require 'servolux' 4 | require 'rack/streaming_proxy/errors' 5 | 6 | class Rack::StreamingProxy::Session 7 | 8 | def initialize(request) 9 | @request = request 10 | end 11 | 12 | # Returns a Rack::StreamingProxy::Response 13 | def start 14 | @piper = Servolux::Piper.new 'r', timeout: 30 15 | @piper.child { child } 16 | @piper.parent { parent } 17 | end 18 | 19 | private 20 | 21 | def child 22 | begin 23 | Rack::StreamingProxy::Proxy.log :debug, "Child starting request to #{@request.uri}" 24 | perform_request 25 | 26 | rescue Exception => e 27 | # Rescue all exceptions to help with development and debugging, as otherwise when exceptions 28 | # occur the child process doesn't crash the parent process. Normally rescuing from Exception is a bad idea, 29 | # but it's the only way to get a stacktrace here for all exceptions including SyntaxError etc, 30 | # and we are simply passing it on so catastrophic exceptions will still be raised up the chain. 31 | Rack::StreamingProxy::Proxy.log :debug, "Child process #{Process.pid} passing on #{e.class}: #{e.message}" 32 | @piper.puts e # Pass on the exception to the parent. 33 | 34 | ensure 35 | Rack::StreamingProxy::Proxy.log :debug, "Child process #{Process.pid} closing connection." 36 | @piper.close 37 | 38 | Rack::StreamingProxy::Proxy.log :info, "Child process #{Process.pid} exiting." 39 | exit!(0) # child needs to exit, always. 40 | end 41 | end 42 | 43 | def parent 44 | Rack::StreamingProxy::Proxy.log :info, "Parent process #{Process.pid} forked a child process #{@piper.pid}." 45 | 46 | response = Rack::StreamingProxy::Response.new(@piper) 47 | return response 48 | end 49 | 50 | def perform_request 51 | http_session = Net::HTTP.new(@request.host, @request.port) 52 | http_session.use_ssl = @request.use_ssl? 53 | 54 | http_session.start do |session| 55 | # Retry the request up to self.class.num_retries_on_5xx times if a 5xx is experienced. 56 | # This is because some 500/503 errors resolve themselves quickly, might as well give it a chance. 57 | # do...while loop as suggested by Matz: http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-core/6745 58 | retries = 1 59 | stop = false 60 | loop do 61 | session.request(@request.http_request) do |response| 62 | # At this point the headers and status are available, but the body has not yet been read. 63 | Rack::StreamingProxy::Proxy.log :debug, "Child got response: #{response.class.name}" 64 | 65 | if response.class <= Net::HTTPServerError # Includes Net::HTTPServiceUnavailable, Net::HTTPInternalServerError 66 | if retries <= Rack::StreamingProxy::Proxy.num_retries_on_5xx 67 | Rack::StreamingProxy::Proxy.log :info, "Child got #{response.code}, retrying (Retry ##{retries})" 68 | sleep 1 69 | retries += 1 70 | next 71 | end 72 | end 73 | stop = true 74 | 75 | Rack::StreamingProxy::Proxy.log :debug, "Child process #{Process.pid} returning Status = #{response.code}." 76 | 77 | process_response(response) 78 | end 79 | 80 | break if stop 81 | end 82 | end 83 | end 84 | 85 | def process_response(response) 86 | 87 | # Raise an exception if the raise_on_5xx config is set, and the response is a 5xx. 88 | # Otherwise continue and put the error body in the pipe. (e.g. Apache error page, for example) 89 | if response.class <= Net::HTTPServerError && Rack::StreamingProxy::Proxy.raise_on_5xx 90 | raise Rack::StreamingProxy::HttpServerError.new "Got a #{response.class.name} (#{response.code}) response while proxying to #{@request.uri}" 91 | end 92 | 93 | # Put the response in the parent's pipe. 94 | @piper.puts response.code 95 | @piper.puts response.class.body_permitted? 96 | 97 | # Could potentially use a one-liner here: 98 | # @piper.puts Hash[response.to_hash.map { |key, value| [key, value.join(', ')] } ] 99 | # But the following three lines seem to be more readable. 100 | # Watch out: response.to_hash and response.each_header returns in different formats! 101 | # to_hash requires the values to be joined with a comma. 102 | headers = {} 103 | response.each_header { |key, value| headers[key] = value } 104 | log_headers :debug, 'Proxy Response Headers:', headers 105 | @piper.puts headers 106 | 107 | response.read_body { |chunk| @piper.puts chunk } 108 | @piper.puts :done 109 | end 110 | 111 | def log_headers(level, title, headers) 112 | Rack::StreamingProxy::Proxy.log level, "+-------------------------------------------------------------" 113 | Rack::StreamingProxy::Proxy.log level, "| #{title}" 114 | Rack::StreamingProxy::Proxy.log level, "+-------------------------------------------------------------" 115 | headers.each { |key, value| Rack::StreamingProxy::Proxy.log level, "| #{key} = #{value.to_s}" } 116 | Rack::StreamingProxy::Proxy.log level, "+-------------------------------------------------------------" 117 | end 118 | 119 | end 120 | -------------------------------------------------------------------------------- /spec/streaming_proxy_spec.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require File.join(File.dirname(__FILE__), %w[spec_helper]) 3 | 4 | APP_PORT = 4321 # hardcoded in proxy.ru as well! 5 | PROXY_PORT = 4322 6 | 7 | shared_examples "rack-streaming-proxy" do 8 | it "passes through to the rest of the stack if block returns false" do 9 | get "/not_proxied" 10 | last_response.should be_ok 11 | last_response.body.should == "not proxied" 12 | end 13 | 14 | it "proxies a request back to the app server" do 15 | get "/", {}, rack_env 16 | last_response.should be_ok 17 | last_response.body.should == "ALL GOOD" 18 | # Expect a Content-Length header field which the origin server sent is 19 | # not deleted by streaming-proxy. 20 | last_response.headers["Content-Length"].should eq '8' 21 | end 22 | 23 | it "handles POST, PUT, and DELETE methods" do 24 | post "/env", {}, rack_env 25 | last_response.should be_ok 26 | last_response.body.should =~ /REQUEST_METHOD: POST/ 27 | put "/env", {}, rack_env 28 | last_response.should be_ok 29 | last_response.body.should =~ /REQUEST_METHOD: PUT/ 30 | delete "/env", {}, rack_env 31 | last_response.should be_ok 32 | last_response.body.should =~ /REQUEST_METHOD: DELETE/ 33 | end 34 | 35 | it "sets a X-Forwarded-For header" do 36 | post "/env", {}, rack_env 37 | last_response.should =~ /HTTP_X_FORWARDED_FOR: 127.0.0.1/ 38 | end 39 | 40 | it "preserves the post body" do 41 | post "/env", {"foo" => "bar"}, rack_env 42 | last_response.body.should =~ /rack.request.form_vars: foo=bar/ 43 | end 44 | 45 | it "raises a Rack::Proxy::StreamingProxy error when something goes wrong" do 46 | Rack::StreamingProxy::Request.should_receive(:new).and_raise(RuntimeError.new("kaboom")) 47 | lambda { get "/" }.should raise_error(RuntimeError, /kaboom/i) 48 | end 49 | 50 | it "does not raise a Rack::Proxy error if the app itself raises something" do 51 | lambda { get "/not_proxied/boom" }.should raise_error(RuntimeError, /app error/) 52 | end 53 | 54 | it "preserves cookies" do 55 | set_cookie "foo" 56 | post "/env", {}, rack_env 57 | 58 | last_response.match("HTTP_COOKIE: ([^\n]*)")[1].should == "foo" 59 | end 60 | 61 | it "preserves authentication info" do 62 | # due to https://github.com/brynary/rack-test/issues/64 63 | encoded_login = ["admin:secret"].pack("m0*") 64 | header('Authorization', "Basic #{encoded_login}") 65 | 66 | post "/env", {}, rack_env 67 | 68 | last_response.match("HTTP_AUTHORIZATION: ([^\n]*)")[1].should == "Basic YWRtaW46c2VjcmV0" 69 | end 70 | 71 | it "preserves arbitrary headers" do 72 | get "/env", {}, rack_env.merge("HTTP_X_FOOHEADER" => "Bar") 73 | last_response.match("HTTP_X_FOOHEADER: ([^\n]*)")[1].should == "Bar" 74 | end 75 | end 76 | 77 | describe Rack::StreamingProxy::Proxy do 78 | include Rack::Test::Methods 79 | 80 | def app 81 | @app ||= Rack::Builder.new do 82 | use Rack::Lint 83 | use Rack::StreamingProxy::Proxy do |req| 84 | # STDERR.puts "== incoming request env ==" 85 | # STDERR.puts req.env 86 | # STDERR.puts "=^ incoming request env ^=" 87 | # STDERR.puts 88 | unless req.path.start_with?("/not_proxied") 89 | url = "http://localhost:#{APP_PORT}#{req.path}" 90 | url << "?#{req.query_string}" unless req.query_string.empty? 91 | # STDERR.puts "PROXYING to #{url}" 92 | url 93 | end 94 | end 95 | run lambda { |env| 96 | raise "app error" if env["PATH_INFO"] =~ /boom/ 97 | [200, {"Content-Type" => "text/plain"}, ["not proxied"]] 98 | } 99 | end 100 | end 101 | 102 | before(:all) do 103 | app_path = File.join(File.dirname(__FILE__), %w[app.ru]) 104 | @app_server = Servolux::Child.new( 105 | # :command => "thin -R #{app_path} -p #{APP_PORT} start", # buffers! 106 | :command => "rackup #{app_path} -p #{APP_PORT}", 107 | :timeout => 30, # all specs should take <30 sec to run 108 | :suspend => 0.25 109 | ) 110 | puts "----- starting app server -----" 111 | @app_server.start 112 | sleep 2 # give it a sec 113 | puts "----- started app server -----" 114 | end 115 | 116 | after(:all) do 117 | puts "----- shutting down app server -----" 118 | @app_server.stop 119 | @app_server.wait 120 | puts "----- app server is stopped -----" 121 | end 122 | 123 | context 'client requests with HTTP/1.0' do 124 | let(:rack_env) { {'HTTP_VERSION' => 'HTTP/1.0'} } 125 | it_behaves_like 'rack-streaming-proxy' 126 | it "does not use chunked encoding when the app server send chunked body" do 127 | get "/stream", {}, rack_env 128 | last_response.should be_ok 129 | # Expect a Transfer-Encoding header is deleted by rack-streaming-proxy 130 | last_response.headers["Transfer-Encoding"].should be_nil 131 | # I expected a Content-Length header which the origin server sent was deleted, 132 | # But the following test failed against my expectation. The reason is 133 | # that a Content-Length header was added in creating Rack::MockResponse 134 | # instance. So I gave up writing this test right now. 135 | # 136 | # last_response.headers["Content-Length"].should be_nil 137 | # 138 | last_response.body.should == <<-EOS 139 | ~~~~~ 0 ~~~~~ 140 | ~~~~~ 1 ~~~~~ 141 | ~~~~~ 2 ~~~~~ 142 | ~~~~~ 3 ~~~~~ 143 | ~~~~~ 4 ~~~~~ 144 | EOS 145 | end 146 | end 147 | 148 | context 'client requests with HTTP/1.1' do 149 | let(:rack_env) { {'HTTP_VERSION' => 'HTTP/1.1'} } 150 | it_behaves_like 'rack-streaming-proxy' 151 | it "uses chunked encoding when the app server send chunked body" do 152 | get "/stream", {}, rack_env 153 | last_response.should be_ok 154 | last_response.headers["Transfer-Encoding"].should == 'chunked' 155 | last_response.headers["Content-Length"].should be_nil 156 | last_response.body.should =~ /^e\r\n~~~~~ 0 ~~~~~\n\r\n/ 157 | end 158 | end 159 | 160 | end 161 | 162 | describe Rack::StreamingProxy::Proxy do 163 | include Rack::Test::Methods 164 | 165 | attr_reader :app 166 | 167 | before(:all) do 168 | app_path = File.join(File.dirname(__FILE__), %w[app.ru]) 169 | @app_server = Servolux::Child.new( 170 | # :command => "thin -R #{app_path} -p #{APP_PORT} start", # buffers! 171 | # :command => "rackup #{app_path} -p #{APP_PORT}", # webrick adds content-length, it should be wrong 172 | :command => "unicorn #{app_path} -p #{APP_PORT} -E none", 173 | :timeout => 30, # all specs should take <30 sec to run 174 | :suspend => 0.25 175 | ) 176 | puts "----- starting app server -----" 177 | @app_server.start 178 | sleep 2 # give it a sec 179 | puts "----- started app server -----" 180 | end 181 | 182 | after(:all) do 183 | puts "----- shutting down app server -----" 184 | @app_server.stop 185 | @app_server.wait 186 | puts "----- app server is stopped -----" 187 | end 188 | 189 | def with_proxy_server 190 | proxy_path = File.join(File.dirname(__FILE__), %w[proxy.ru]) 191 | @proxy_server = Servolux::Child.new( 192 | :command => "unicorn #{proxy_path} -p #{PROXY_PORT} -E none", 193 | :timeout => 10, 194 | :suspend => 0.25 195 | ) 196 | puts "----- starting proxy server -----" 197 | @proxy_server.start 198 | sleep 2 199 | puts "----- started proxy server -----" 200 | yield 201 | ensure 202 | puts "----- shutting down proxy server -----" 203 | @proxy_server.stop 204 | @proxy_server.wait 205 | puts "----- proxy server is stopped -----" 206 | end 207 | 208 | # this is the most critical spec: it makes sure things are actually streamed, not buffered 209 | # MEMO: only unicorn worked. webrick, thin, and puma did not progressively stream 210 | it "streams data from the app server to the client" do 211 | @app = Rack::Builder.new do 212 | use Rack::Lint 213 | run lambda { |env| 214 | body = [] 215 | Net::HTTP.start("localhost", PROXY_PORT) do |http| 216 | http.request_get("/slow_stream") do |response| 217 | response.read_body do |chunk| 218 | body << "#{Time.now.to_i}\n" 219 | end 220 | end 221 | end 222 | [200, {"Content-Type" => "text/plain"}, body] 223 | } 224 | end 225 | 226 | with_proxy_server do 227 | get "/" 228 | last_response.should be_ok 229 | times = last_response.body.split("\n").map {|l| l.to_i} 230 | unless (times.last - times.first) >= 2 231 | fail "expected receive time of first chunk to be at least two seconds before the last chunk, but the times were: #{times.join(', ')}" 232 | end 233 | end 234 | end 235 | end 236 | --------------------------------------------------------------------------------