yahns.git  about / heads / tags
sleepy, multi-threaded, non-blocking application server for Ruby
blob bc902f87f7be45bf04ea968ca738fecc7bca4138 6207 bytes (raw)
$ git show HEAD:lib/yahns/proxy_pass.rb	# shows this blob on the CLI

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
 
# -*- encoding: binary -*-
# Copyright (C) 2013-2019 all contributors <yahns-public@yhbt.net>
# License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
# frozen_string_literal: true
require 'socket'
require 'rack/request'
require 'timeout' # only for Timeout::Error
require_relative 'proxy_http_response'
require_relative 'req_res'

# Yahns::ProxyPass is a Rack (hijack) app which allows yahns to
# act as a fully-buffering reverse proxy to protect backends
# from slow HTTP clients.
#
# Yahns::ProxyPass relies on the default behavior of yahns to do
# full input and output buffering. Output buffering is lazy,
# meaning it allows streaming output in the best case and
# will only buffer if the client cannot keep up with the server.
#
# The goal of this reverse proxy is to act as a sponge on the same LAN
# or host to any backend HTTP server not optimized for slow clients.
# Yahns::ProxyPass accomplishes this by handling all the slow clients
# internally within yahns itself to minimize time spent in the backend
# HTTP server waiting on slow clients.
#
# It does not do load balancing (we rely on Varnish for that).
# Here is the exact config we use with Varnish, which uses
# the +:response_headers+ option to hide some Varnish headers
# from clients:
#
#    run Yahns::ProxyPass.new('http://127.0.0.1:6081',
#            response_headers: {
#              'Age' => :ignore,
#              'X-Varnish' => :ignore,
#              'Via' => :ignore
#            })
#
# This is NOT a generic Rack app and must be run with yahns.
# It uses +rack.hijack+, so compatibility with logging
# middlewares (e.g. Rack::CommonLogger) is not great and
# timing information gets lost.
#
# This provides HTTPS termination for our mail archives:
# https://yhbt.net/yahns-public/
#
# See https://yhbt.net/yahns.git/tree/examples/https_proxy_pass.conf.rb
# and https://yhbt.net/yahns.git/tree/examples/proxy_pass.ru for examples
class Yahns::ProxyPass
  attr_reader :proxy_buffering, :response_headers # :nodoc:

  # +dest+ must be an HTTP URL with optional variables prefixed with '$'.
  # +dest+ may refer to the path to a Unix domain socket in the form:
  #
  #     unix:/absolute/path/to/socket
  #
  # Variables which may be used in the +dest+ parameter include:
  #
  # - $url - the entire URL used to make the request
  # - $path - the unescaped PATH_INFO of the HTTP request
  # - $fullpath - $path with QUERY_STRING
  # - $host - the hostname in the Host: header
  #
  # For Unix domain sockets, variables may be separated from the
  # socket path via: ":/".  For example:
  #
  #     unix:/absolute/path/to/socket:/$host/$fullpath
  #
  # Currently :response_headers is the only +opts+ supported.
  # :response_headers is a Hash containing a "from => to" mapping
  # of response headers.  The special value of +:ignore+ indicates
  # the header from the backend HTTP server will be ignored instead
  # of being blindly passed on to the client.
  def initialize(dest, opts = { response_headers: { 'Server' => :ignore } })
    case dest
    when %r{\Aunix:([^:]+)(?::(/.*))?\z}
      path = $2
      @sockaddr = Socket.sockaddr_un($1)
    when %r{\Ahttp://([^/]+)(/.*)?\z}
      path = $2
      host, port = $1.split(':')
      @sockaddr = Socket.sockaddr_in(port || 80, host)
    else
      raise ArgumentError, "destination must be an HTTP URL or unix: path"
    end
    @response_headers = opts[:response_headers] || {}
    @proxy_buffering = opts[:proxy_buffering]
    @proxy_buffering = true if @proxy_buffering.nil? # allow false

    # It's wrong to send the backend Server tag through.  Let users say
    # { "Server => "yahns" } if they want to advertise for us, but don't
    # advertise by default (for security)
    @response_headers['Server'] ||= :ignore
    init_path_vars(path)
  end

  def init_path_vars(path) # :nodoc:
    path ||= '$fullpath'
    # methods from Rack::Request we want:
    allow = %w(fullpath host_with_port host port url path)
    want = path.scan(/\$(\w+)/).flatten! || []
    diff = want - allow
    diff.empty? or
             raise ArgumentError, "vars not allowed: #{diff.uniq.join(' ')}"

    # kill leading slash just in case...
    @path = path.gsub(%r{\A/(\$(?:fullpath|path))}, '\1')
  end

  def call(env) # :nodoc:
    # 3-way handshake for TCP backends while we generate the request header
    rr = Yahns::ReqRes.start(@sockaddr)
    c = env['rack.hijack'].call # Yahns::HttpClient#call

    req = Rack::Request.new(env)
    req = @path.gsub(/\$(\w+)/) { req.__send__($1) }

    # start the connection asynchronously and early so TCP can do a
    case ver = env['HTTP_VERSION']
    when 'HTTP/1.1' # leave alone, response may be chunked
    else # no chunking for HTTP/1.0 and HTTP/0.9
      ver = 'HTTP/1.0'.freeze
    end

    addr = env['REMOTE_ADDR']
    xff = env['HTTP_X_FORWARDED_FOR']
    xff = xff =~ /\S/ ? "#{xff}, #{addr}" : addr
    req = "#{env['REQUEST_METHOD']} #{req} #{ver}\r\n" \
          "X-Forwarded-Proto: #{env['rack.url_scheme']}\r\n" \
          "X-Forwarded-For: #{xff}\r\n".dup

    # pass most HTTP_* headers through as-is
    chunked = false
    env.each do |key, val|
      %r{\AHTTP_(\w+)\z} =~ key or next
      key = $1
      # trailers are folded into the header, so do not send the Trailer:
      # header in the request
      next if /\A(?:VERSION|CONNECTION|KEEP_ALIVE|X_FORWARDED_FOR|TRAILER)/ =~
         key
      'TRANSFER_ENCODING'.freeze == key && val =~ /\bchunked\b/i and
        chunked = true
      key.tr!('_'.freeze, '-'.freeze)
      req << "#{key}: #{val}\r\n"
    end

    # special cases which Rack does not prefix:
    ctype = env["CONTENT_TYPE"] and req << "Content-Type: #{ctype}\r\n"
    clen = env["CONTENT_LENGTH"] and req << "Content-Length: #{clen}\r\n"
    input = chunked || (clen && clen.to_i > 0) ? env['rack.input'] : nil

    # finally, prepare to emit the headers
    rr.req_start(c, req << "\r\n".freeze, input, chunked, self)

    # this probably breaks fewer middlewares than returning whatever else...
    [ 500, [], [] ]
  rescue => e
    Yahns::Log.exception(env['rack.logger'], 'proxy_pass', e)
    [ 502, { 'Content-Length' => '0', 'Content-Type' => 'text/plain' }, [] ]
  end
end

git clone git://yhbt.net/yahns.git
git clone https://yhbt.net/yahns.git