proxy.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. # (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
  2. # Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
  3. """
  4. An application that proxies WSGI requests to a remote server.
  5. TODO:
  6. * Send ``Via`` header? It's not clear to me this is a Via in the
  7. style of a typical proxy.
  8. * Other headers or metadata? I put in X-Forwarded-For, but that's it.
  9. * Signed data of non-HTTP keys? This would be for things like
  10. REMOTE_USER.
  11. * Something to indicate what the original URL was? The original host,
  12. scheme, and base path.
  13. * Rewriting ``Location`` headers? mod_proxy does this.
  14. * Rewriting body? (Probably not on this one -- that can be done with
  15. a different middleware that wraps this middleware)
  16. * Example::
  17. use = egg:Paste#proxy
  18. address = http://server3:8680/exist/rest/db/orgs/sch/config/
  19. allowed_request_methods = GET
  20. """
  21. import httplib
  22. import urlparse
  23. import urllib
  24. from paste import httpexceptions
  25. from paste.util.converters import aslist
  26. # Remove these headers from response (specify lower case header
  27. # names):
  28. filtered_headers = (
  29. 'transfer-encoding',
  30. 'connection',
  31. 'keep-alive',
  32. 'proxy-authenticate',
  33. 'proxy-authorization',
  34. 'te',
  35. 'trailers',
  36. 'upgrade',
  37. )
  38. class Proxy(object):
  39. def __init__(self, address, allowed_request_methods=(),
  40. suppress_http_headers=()):
  41. self.address = address
  42. self.parsed = urlparse.urlsplit(address)
  43. self.scheme = self.parsed[0].lower()
  44. self.host = self.parsed[1]
  45. self.path = self.parsed[2]
  46. self.allowed_request_methods = [
  47. x.lower() for x in allowed_request_methods if x]
  48. self.suppress_http_headers = [
  49. x.lower() for x in suppress_http_headers if x]
  50. def __call__(self, environ, start_response):
  51. if (self.allowed_request_methods and
  52. environ['REQUEST_METHOD'].lower() not in self.allowed_request_methods):
  53. return httpexceptions.HTTPBadRequest("Disallowed")(environ, start_response)
  54. if self.scheme == 'http':
  55. ConnClass = httplib.HTTPConnection
  56. elif self.scheme == 'https':
  57. ConnClass = httplib.HTTPSConnection
  58. else:
  59. raise ValueError(
  60. "Unknown scheme for %r: %r" % (self.address, self.scheme))
  61. conn = ConnClass(self.host)
  62. headers = {}
  63. for key, value in environ.items():
  64. if key.startswith('HTTP_'):
  65. key = key[5:].lower().replace('_', '-')
  66. if key == 'host' or key in self.suppress_http_headers:
  67. continue
  68. headers[key] = value
  69. headers['host'] = self.host
  70. if 'REMOTE_ADDR' in environ:
  71. headers['x-forwarded-for'] = environ['REMOTE_ADDR']
  72. if environ.get('CONTENT_TYPE'):
  73. headers['content-type'] = environ['CONTENT_TYPE']
  74. if environ.get('CONTENT_LENGTH'):
  75. headers['content-length'] = environ['CONTENT_LENGTH']
  76. length = int(environ['CONTENT_LENGTH'])
  77. body = environ['wsgi.input'].read(length)
  78. else:
  79. body = ''
  80. path_info = urllib.quote(environ['PATH_INFO'])
  81. if self.path:
  82. request_path = path_info
  83. if request_path[0] == '/':
  84. request_path = request_path[1:]
  85. path = urlparse.urljoin(self.path, request_path)
  86. else:
  87. path = path_info
  88. if environ.get('QUERY_STRING'):
  89. path += '?' + environ['QUERY_STRING']
  90. conn.request(environ['REQUEST_METHOD'],
  91. path,
  92. body, headers)
  93. res = conn.getresponse()
  94. headers_out = parse_headers(res.msg)
  95. status = '%s %s' % (res.status, res.reason)
  96. start_response(status, headers_out)
  97. # @@: Default?
  98. length = res.getheader('content-length')
  99. if length is not None:
  100. body = res.read(int(length))
  101. else:
  102. body = res.read()
  103. conn.close()
  104. return [body]
  105. def make_proxy(global_conf, address, allowed_request_methods="",
  106. suppress_http_headers=""):
  107. """
  108. Make a WSGI application that proxies to another address:
  109. ``address``
  110. the full URL ending with a trailing ``/``
  111. ``allowed_request_methods``:
  112. a space seperated list of request methods (e.g., ``GET POST``)
  113. ``suppress_http_headers``
  114. a space seperated list of http headers (lower case, without
  115. the leading ``http_``) that should not be passed on to target
  116. host
  117. """
  118. allowed_request_methods = aslist(allowed_request_methods)
  119. suppress_http_headers = aslist(suppress_http_headers)
  120. return Proxy(
  121. address,
  122. allowed_request_methods=allowed_request_methods,
  123. suppress_http_headers=suppress_http_headers)
  124. class TransparentProxy(object):
  125. """
  126. A proxy that sends the request just as it was given, including
  127. respecting HTTP_HOST, wsgi.url_scheme, etc.
  128. This is a way of translating WSGI requests directly to real HTTP
  129. requests. All information goes in the environment; modify it to
  130. modify the way the request is made.
  131. If you specify ``force_host`` (and optionally ``force_scheme``)
  132. then HTTP_HOST won't be used to determine where to connect to;
  133. instead a specific host will be connected to, but the ``Host``
  134. header in the request will remain intact.
  135. """
  136. def __init__(self, force_host=None,
  137. force_scheme='http'):
  138. self.force_host = force_host
  139. self.force_scheme = force_scheme
  140. def __repr__(self):
  141. return '<%s %s force_host=%r force_scheme=%r>' % (
  142. self.__class__.__name__,
  143. hex(id(self)),
  144. self.force_host, self.force_scheme)
  145. def __call__(self, environ, start_response):
  146. scheme = environ['wsgi.url_scheme']
  147. if self.force_host is None:
  148. conn_scheme = scheme
  149. else:
  150. conn_scheme = self.force_scheme
  151. if conn_scheme == 'http':
  152. ConnClass = httplib.HTTPConnection
  153. elif conn_scheme == 'https':
  154. ConnClass = httplib.HTTPSConnection
  155. else:
  156. raise ValueError(
  157. "Unknown scheme %r" % scheme)
  158. if 'HTTP_HOST' not in environ:
  159. raise ValueError(
  160. "WSGI environ must contain an HTTP_HOST key")
  161. host = environ['HTTP_HOST']
  162. if self.force_host is None:
  163. conn_host = host
  164. else:
  165. conn_host = self.force_host
  166. conn = ConnClass(conn_host)
  167. headers = {}
  168. for key, value in environ.items():
  169. if key.startswith('HTTP_'):
  170. key = key[5:].lower().replace('_', '-')
  171. headers[key] = value
  172. headers['host'] = host
  173. if 'REMOTE_ADDR' in environ and 'HTTP_X_FORWARDED_FOR' not in environ:
  174. headers['x-forwarded-for'] = environ['REMOTE_ADDR']
  175. if environ.get('CONTENT_TYPE'):
  176. headers['content-type'] = environ['CONTENT_TYPE']
  177. if environ.get('CONTENT_LENGTH'):
  178. length = int(environ['CONTENT_LENGTH'])
  179. body = environ['wsgi.input'].read(length)
  180. elif 'CONTENT_LENGTH' not in environ:
  181. body = ''
  182. length = 0
  183. else:
  184. body = ''
  185. length = 0
  186. path = (environ.get('SCRIPT_NAME', '')
  187. + environ.get('PATH_INFO', ''))
  188. path = urllib.quote(path)
  189. if 'QUERY_STRING' in environ:
  190. path += '?' + environ['QUERY_STRING']
  191. conn.request(environ['REQUEST_METHOD'],
  192. path, body, headers)
  193. res = conn.getresponse()
  194. headers_out = parse_headers(res.msg)
  195. status = '%s %s' % (res.status, res.reason)
  196. start_response(status, headers_out)
  197. # @@: Default?
  198. length = res.getheader('content-length')
  199. if length is not None:
  200. body = res.read(int(length))
  201. else:
  202. body = res.read()
  203. conn.close()
  204. return [body]
  205. def parse_headers(message):
  206. """
  207. Turn a Message object into a list of WSGI-style headers.
  208. """
  209. headers_out = []
  210. for full_header in message.headers:
  211. if not full_header:
  212. # Shouldn't happen, but we'll just ignore
  213. continue
  214. if full_header[0].isspace():
  215. # Continuation line, add to the last header
  216. if not headers_out:
  217. raise ValueError(
  218. "First header starts with a space (%r)" % full_header)
  219. last_header, last_value = headers_out.pop()
  220. value = last_value + ' ' + full_header.strip()
  221. headers_out.append((last_header, value))
  222. continue
  223. try:
  224. header, value = full_header.split(':', 1)
  225. except:
  226. raise ValueError("Invalid header: %r" % full_header)
  227. value = value.strip()
  228. if header.lower() not in filtered_headers:
  229. headers_out.append((header, value))
  230. return headers_out
  231. def make_transparent_proxy(
  232. global_conf, force_host=None, force_scheme='http'):
  233. """
  234. Create a proxy that connects to a specific host, but does
  235. absolutely no other filtering, including the Host header.
  236. """
  237. return TransparentProxy(force_host=force_host,
  238. force_scheme=force_scheme)