blob: 7b4a8ece218fcdd0ae6bd7bd668975c6714d0dab [file] [log] [blame]
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
proxy.py
~~~~~~~~
HTTP Proxy Server in Python.
:copyright: (c) 2013-2018 by Abhinav Singh.
:license: BSD, see LICENSE for more details.
"""
import os
import sys
import errno
import base64
import socket
import select
import logging
import argparse
import datetime
import threading
import json
from collections import namedtuple
if os.name != 'nt':
import resource
VERSION = (0, 3)
__version__ = '.'.join(map(str, VERSION[0:2]))
__description__ = 'Lightweight HTTP, HTTPS, WebSockets Proxy Server in a single Python file'
__author__ = 'Abhinav Singh'
__author_email__ = 'mailsforabhinav@gmail.com'
__homepage__ = 'https://github.com/abhinavsingh/proxy.py'
__download_url__ = '%s/archive/master.zip' % __homepage__
__license__ = 'BSD'
logger = logging.getLogger(__name__)
PY3 = sys.version_info[0] == 3
if PY3: # pragma: no cover
text_type = str
binary_type = bytes
from urllib import parse as urlparse
else: # pragma: no cover
text_type = unicode
binary_type = str
import urlparse
def text_(s, encoding='utf-8', errors='strict'): # pragma: no cover
"""Utility to ensure text-like usability.
If ``s`` is an instance of ``binary_type``, return
``s.decode(encoding, errors)``, otherwise return ``s``"""
if isinstance(s, binary_type):
return s.decode(encoding, errors)
return s
def bytes_(s, encoding='utf-8', errors='strict'): # pragma: no cover
"""Utility to ensure binary-like usability.
If ``s`` is an instance of ``text_type``, return
``s.encode(encoding, errors)``, otherwise return ``s``"""
if isinstance(s, text_type):
return s.encode(encoding, errors)
return s
version = bytes_(__version__)
CRLF, COLON, SP = b'\r\n', b':', b' '
PROXY_AGENT_HEADER = b'Proxy-agent: proxy.py v' + version
PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT = CRLF.join([
b'HTTP/1.1 200 Connection established',
PROXY_AGENT_HEADER,
CRLF
])
BAD_GATEWAY_RESPONSE_PKT = CRLF.join([
b'HTTP/1.1 502 Bad Gateway',
PROXY_AGENT_HEADER,
b'Content-Length: 11',
b'Connection: close',
CRLF
]) + b'Bad Gateway'
PROXY_AUTHENTICATION_REQUIRED_RESPONSE_PKT = CRLF.join([
b'HTTP/1.1 407 Proxy Authentication Required',
PROXY_AGENT_HEADER,
b'Content-Length: 29',
b'Connection: close',
CRLF
]) + b'Proxy Authentication Required'
class ChunkParser(object):
"""HTTP chunked encoding response parser."""
states = namedtuple('ChunkParserStates', (
'WAITING_FOR_SIZE',
'WAITING_FOR_DATA',
'COMPLETE'
))(1, 2, 3)
def __init__(self):
self.state = ChunkParser.states.WAITING_FOR_SIZE
self.body = b'' # Parsed chunks
self.chunk = b'' # Partial chunk received
self.size = None # Expected size of next following chunk
def parse(self, data):
more = True if len(data) > 0 else False
while more:
more, data = self.process(data)
def process(self, data):
if self.state == ChunkParser.states.WAITING_FOR_SIZE:
# Consume prior chunk in buffer
# in case chunk size without CRLF was received
data = self.chunk + data
self.chunk = b''
# Extract following chunk data size
line, data = HttpParser.split(data)
if not line: # CRLF not received
self.chunk = data
data = b''
else:
self.size = int(line, 16)
self.state = ChunkParser.states.WAITING_FOR_DATA
elif self.state == ChunkParser.states.WAITING_FOR_DATA:
remaining = self.size - len(self.chunk)
self.chunk += data[:remaining]
data = data[remaining:]
if len(self.chunk) == self.size:
if data[:len(CRLF)] == CRLF:
data = data[len(CRLF):]
self.body += self.chunk
if self.size == 0:
self.body += data
data = b''
self.state = ChunkParser.states.COMPLETE
else:
self.state = ChunkParser.states.WAITING_FOR_SIZE
self.chunk = b''
self.size = None
return len(data) > 0, data
class HttpParser(object):
"""HTTP request/response parser."""
states = namedtuple('HttpParserStates', (
'INITIALIZED',
'LINE_RCVD',
'RCVING_HEADERS',
'HEADERS_COMPLETE',
'RCVING_BODY',
'COMPLETE'))(1, 2, 3, 4, 5, 6)
types = namedtuple('HttpParserTypes', (
'REQUEST_PARSER',
'RESPONSE_PARSER'
))(1, 2)
def __init__(self, parser_type):
assert parser_type in (HttpParser.types.REQUEST_PARSER, HttpParser.types.RESPONSE_PARSER)
self.type = parser_type
self.state = HttpParser.states.INITIALIZED
self.raw = b''
self.buffer = b''
self.headers = dict()
self.body = None
self.method = None
self.url = None
self.code = None
self.reason = None
self.version = None
self.chunk_parser = None
def is_chunked_encoded_response(self):
return self.type == HttpParser.types.RESPONSE_PARSER and \
b'transfer-encoding' in self.headers and \
self.headers[b'transfer-encoding'][1].lower() == b'chunked'
def parse(self, data):
self.raw += data
data = self.buffer + data
self.buffer = b''
more = True if len(data) > 0 else False
while more:
more, data = self.process(data)
self.buffer = data
def process(self, data):
if self.state in (HttpParser.states.HEADERS_COMPLETE,
HttpParser.states.RCVING_BODY,
HttpParser.states.COMPLETE) and \
(self.method == b'POST' or self.type == HttpParser.types.RESPONSE_PARSER):
if not self.body:
self.body = b''
if b'content-length' in self.headers:
self.state = HttpParser.states.RCVING_BODY
self.body += data
if len(self.body) >= int(self.headers[b'content-length'][1]):
self.state = HttpParser.states.COMPLETE
elif self.is_chunked_encoded_response():
if not self.chunk_parser:
self.chunk_parser = ChunkParser()
self.chunk_parser.parse(data)
if self.chunk_parser.state == ChunkParser.states.COMPLETE:
self.body = self.chunk_parser.body
self.state = HttpParser.states.COMPLETE
return False, b''
line, data = HttpParser.split(data)
if line is False:
return line, data
if self.state == HttpParser.states.INITIALIZED:
self.process_line(line)
elif self.state in (HttpParser.states.LINE_RCVD, HttpParser.states.RCVING_HEADERS):
self.process_header(line)
# When connect request is received without a following host header
# See `TestHttpParser.test_connect_request_without_host_header_request_parse` for details
if self.state == HttpParser.states.LINE_RCVD and \
self.type == HttpParser.types.REQUEST_PARSER and \
self.method == b'CONNECT' and \
data == CRLF:
self.state = HttpParser.states.COMPLETE
# When raw request has ended with \r\n\r\n and no more http headers are expected
# See `TestHttpParser.test_request_parse_without_content_length` and
# `TestHttpParser.test_response_parse_without_content_length` for details
elif self.state == HttpParser.states.HEADERS_COMPLETE and \
self.type == HttpParser.types.REQUEST_PARSER and \
self.method != b'POST' and \
self.raw.endswith(CRLF * 2):
self.state = HttpParser.states.COMPLETE
elif self.state == HttpParser.states.HEADERS_COMPLETE and \
self.type == HttpParser.types.REQUEST_PARSER and \
self.method == b'POST' and \
(b'content-length' not in self.headers or
(b'content-length' in self.headers and
int(self.headers[b'content-length'][1]) == 0)) and \
self.raw.endswith(CRLF * 2):
self.state = HttpParser.states.COMPLETE
return len(data) > 0, data
def process_line(self, data):
line = data.split(SP)
if self.type == HttpParser.types.REQUEST_PARSER:
self.method = line[0].upper()
self.url = urlparse.urlsplit(line[1])
self.version = line[2]
else:
self.version = line[0]
self.code = line[1]
self.reason = b' '.join(line[2:])
self.state = HttpParser.states.LINE_RCVD
def process_header(self, data):
if len(data) == 0:
if self.state == HttpParser.states.RCVING_HEADERS:
self.state = HttpParser.states.HEADERS_COMPLETE
elif self.state == HttpParser.states.LINE_RCVD:
self.state = HttpParser.states.RCVING_HEADERS
else:
self.state = HttpParser.states.RCVING_HEADERS
parts = data.split(COLON)
key = parts[0].strip()
value = COLON.join(parts[1:]).strip()
self.headers[key.lower()] = (key, value)
def build_url(self):
if not self.url:
return b'/None'
url = self.url.path
if url == b'':
url = b'/'
if not self.url.query == b'':
url += b'?' + self.url.query
if not self.url.fragment == b'':
url += b'#' + self.url.fragment
return url
def build(self, del_headers=None, add_headers=None):
req = b' '.join([self.method, self.build_url(), self.version])
req += CRLF
if not del_headers:
del_headers = []
for k in self.headers:
if k not in del_headers:
req += self.build_header(self.headers[k][0], self.headers[k][1]) + CRLF
if not add_headers:
add_headers = []
for k in add_headers:
req += self.build_header(k[0], k[1]) + CRLF
req += CRLF
if self.body:
req += self.body
return req
@staticmethod
def build_header(k, v):
return k + b': ' + v
@staticmethod
def split(data):
pos = data.find(CRLF)
if pos == -1:
return False, data
line = data[:pos]
data = data[pos + len(CRLF):]
return line, data
class Connection(object):
"""TCP server/client connection abstraction."""
def __init__(self, what):
self.conn = None
self.buffer = b''
self.closed = False
self.what = what # server or client
def send(self, data):
# TODO: Gracefully handle BrokenPipeError exceptions
return self.conn.send(data)
def recv(self, bufsiz=8192):
try:
data = self.conn.recv(bufsiz)
if len(data) == 0:
logger.debug('rcvd 0 bytes from %s' % self.what)
return None
logger.debug('rcvd %d bytes from %s' % (len(data), self.what))
return data
except Exception as e:
if e.errno == errno.ECONNRESET:
logger.debug('%r' % e)
else:
logger.exception(
'Exception while receiving from connection %s %r with reason %r' % (self.what, self.conn, e))
return None
def close(self):
self.conn.close()
self.closed = True
def buffer_size(self):
return len(self.buffer)
def has_buffer(self):
return self.buffer_size() > 0
def queue(self, data):
self.buffer += data
def flush(self):
sent = self.send(self.buffer)
self.buffer = self.buffer[sent:]
logger.debug('flushed %d bytes to %s' % (sent, self.what))
class Server(Connection):
"""Establish connection to destination server."""
def __init__(self, host, port, host_resolver=None):
super(Server, self).__init__(b'server')
# Hostname IP resolution.
if host_resolver and host in host_resolver:
host = host_resolver[host]
self.addr = (host, int(port))
def __del__(self):
if self.conn:
self.close()
def connect(self):
self.conn = socket.create_connection((self.addr[0], self.addr[1]))
class Client(Connection):
"""Accepted client connection."""
def __init__(self, conn, addr):
super(Client, self).__init__(b'client')
self.conn = conn
self.addr = addr
class ProxyError(Exception):
pass
class ProxyConnectionFailed(ProxyError):
def __init__(self, host, port, reason):
self.host = host
self.port = port
self.reason = reason
def __str__(self):
return '<ProxyConnectionFailed - %s:%s - %s>' % (self.host, self.port, self.reason)
class ProxyAuthenticationFailed(ProxyError):
pass
class Proxy(threading.Thread):
"""HTTP proxy implementation.
Accepts `Client` connection object and act as a proxy between client and server.
"""
def __init__(self, client, auth_code=None, server_recvbuf_size=8192, client_recvbuf_size=8192, host_resolver=None):
super(Proxy, self).__init__()
self.start_time = self._now()
self.last_activity = self.start_time
self.auth_code = auth_code
self.client = client
self.client_recvbuf_size = client_recvbuf_size
self.server = None
self.server_recvbuf_size = server_recvbuf_size
self.host_resolver = host_resolver
self.request = HttpParser(HttpParser.types.REQUEST_PARSER)
self.response = HttpParser(HttpParser.types.RESPONSE_PARSER)
@staticmethod
def _now():
return datetime.datetime.utcnow()
def _inactive_for(self):
return (self._now() - self.last_activity).seconds
def _is_inactive(self):
return self._inactive_for() > 30
def _process_request(self, data):
# once we have connection to the server
# we don't parse the http request packets
# any further, instead just pipe incoming
# data from client to server
if self.server and not self.server.closed:
self.server.queue(data)
return
# parse http request
self.request.parse(data)
# once http request parser has reached the state complete
# we attempt to establish connection to destination server
if self.request.state == HttpParser.states.COMPLETE:
logger.debug('request parser is in state complete')
if self.auth_code:
if b'proxy-authorization' not in self.request.headers or \
self.request.headers[b'proxy-authorization'][1] != self.auth_code:
raise ProxyAuthenticationFailed()
if self.request.method == b'CONNECT':
host, port = self.request.url.path.split(COLON)
elif self.request.url:
host, port = self.request.url.hostname, self.request.url.port if self.request.url.port else 80
else:
raise Exception('Invalid request\n%s' % self.request.raw)
self.server = Server(host, port, self.host_resolver)
try:
logger.debug('connecting to server %s:%s' % (host, port))
self.server.connect()
logger.debug('connected to server %s:%s' % (host, port))
except Exception as e: # TimeoutError, socket.gaierror
self.server.closed = True
raise ProxyConnectionFailed(host, port, repr(e))
# for http connect methods (https requests)
# queue appropriate response for client
# notifying about established connection
if self.request.method == b'CONNECT':
self.client.queue(PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT)
# for usual http requests, re-build request packet
# and queue for the server with appropriate headers
else:
self.server.queue(self.request.build(
del_headers=[b'proxy-authorization', b'proxy-connection', b'connection', b'keep-alive'],
add_headers=[(b'Via', b'1.1 proxy.py v%s' % version), (b'Connection', b'Close')]
))
def _process_response(self, data):
# parse incoming response packet
# only for non-https requests
if not self.request.method == b'CONNECT':
self.response.parse(data)
# queue data for client
self.client.queue(data)
def _access_log(self):
host, port = self.server.addr if self.server else (None, None)
if self.request.method == b'CONNECT':
logger.info(
'%s:%s - %s %s:%s' % (self.client.addr[0], self.client.addr[1], self.request.method, host, port))
elif self.request.method:
logger.info('%s:%s - %s %s:%s%s - %s %s - %s bytes' % (
self.client.addr[0], self.client.addr[1], self.request.method, host, port, self.request.build_url(),
self.response.code, self.response.reason, len(self.response.raw)))
def _get_waitable_lists(self):
rlist, wlist, xlist = [self.client.conn], [], []
if self.client.has_buffer():
wlist.append(self.client.conn)
if self.server and not self.server.closed:
rlist.append(self.server.conn)
if self.server and not self.server.closed and self.server.has_buffer():
wlist.append(self.server.conn)
return rlist, wlist, xlist
def _process_wlist(self, w):
if self.client.conn in w:
logger.debug('client is ready for writes, flushing client buffer')
self.client.flush()
if self.server and not self.server.closed and self.server.conn in w:
logger.debug('server is ready for writes, flushing server buffer')
self.server.flush()
def _process_rlist(self, r):
"""Returns True if connection to client must be closed."""
if self.client.conn in r:
logger.debug('client is ready for reads, reading')
data = self.client.recv(self.client_recvbuf_size)
self.last_activity = self._now()
if not data:
logger.debug('client closed connection, breaking')
return True
try:
self._process_request(data)
except (ProxyAuthenticationFailed, ProxyConnectionFailed) as e:
logger.exception(e)
self.client.queue(Proxy._get_response_pkt_by_exception(e))
self.client.flush()
return True
if self.server and not self.server.closed and self.server.conn in r:
logger.debug('server is ready for reads, reading')
data = self.server.recv(self.server_recvbuf_size)
self.last_activity = self._now()
if not data:
logger.debug('server closed connection')
self.server.close()
self.request = HttpParser(HttpParser.types.REQUEST_PARSER)
else:
self._process_response(data)
return False
def _process(self):
while True:
rlist, wlist, xlist = self._get_waitable_lists()
r, w, x = select.select(rlist, wlist, xlist, 1)
self._process_wlist(w)
if self._process_rlist(r):
break
if self.client.buffer_size() == 0:
if self.response.state == HttpParser.states.COMPLETE:
logger.debug('client buffer is empty and response state is complete, breaking')
break
if self._is_inactive():
logger.debug('client buffer is empty and maximum inactivity has reached, breaking')
break
@staticmethod
def _get_response_pkt_by_exception(e):
if e.__class__.__name__ == 'ProxyAuthenticationFailed':
return PROXY_AUTHENTICATION_REQUIRED_RESPONSE_PKT
if e.__class__.__name__ == 'ProxyConnectionFailed':
return BAD_GATEWAY_RESPONSE_PKT
def run(self):
logger.debug('Proxying connection %r' % self.client.conn)
try:
self._process()
except KeyboardInterrupt:
pass
except Exception as e:
logger.exception('Exception while handling connection %r with reason %r' % (self.client.conn, e))
finally:
logger.debug(
'closing client connection with pending client buffer size %d bytes' % self.client.buffer_size())
self.client.close()
if self.server:
logger.debug(
'closed client connection with pending server buffer size %d bytes' % self.server.buffer_size())
self._access_log()
logger.debug('Closing proxy for connection %r at address %r' % (self.client.conn, self.client.addr))
class TCP(object):
"""TCP server implementation.
Subclass MUST implement `handle` method. It accepts an instance of accepted `Client` connection.
"""
def __init__(self, hostname='127.0.0.1', port=8899, backlog=100):
self.hostname = hostname
self.port = port
self.backlog = backlog
self.socket = None
def handle(self, client):
raise NotImplementedError()
def run(self):
try:
logger.info('Starting server on port %d' % self.port)
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.socket.bind((self.hostname, self.port))
self.socket.listen(self.backlog)
while True:
conn, addr = self.socket.accept()
client = Client(conn, addr)
self.handle(client)
except Exception as e:
logger.exception('Exception while running the server %r' % e)
finally:
logger.info('Closing server socket')
self.socket.close()
class HTTP(TCP):
"""HTTP proxy server implementation.
Spawns new process to proxy accepted client connection.
"""
def __init__(self, hostname='127.0.0.1', port=8899, backlog=100,
auth_code=None, server_recvbuf_size=8192, client_recvbuf_size=8192,
host_resolver=None):
super(HTTP, self).__init__(hostname, port, backlog)
self.auth_code = auth_code
self.client_recvbuf_size = client_recvbuf_size
self.server_recvbuf_size = server_recvbuf_size
self.host_resolver = host_resolver
def handle(self, client):
proxy = Proxy(client,
auth_code=self.auth_code,
server_recvbuf_size=self.server_recvbuf_size,
client_recvbuf_size=self.client_recvbuf_size,
host_resolver=self.host_resolver)
proxy.daemon = True
proxy.start()
def set_open_file_limit(soft_limit):
"""Configure open file description soft limit on supported OS."""
if os.name != 'nt': # resource module not available on Windows OS
curr_soft_limit, curr_hard_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
if curr_soft_limit < soft_limit < curr_hard_limit:
resource.setrlimit(resource.RLIMIT_NOFILE, (soft_limit, curr_hard_limit))
logger.info('Open file descriptor soft limit set to %d' % soft_limit)
def main():
parser = argparse.ArgumentParser(
description='proxy.py v%s' % __version__,
epilog='Having difficulty using proxy.py? Report at: %s/issues/new' % __homepage__
)
parser.add_argument('--hostname', default='127.0.0.1', help='Default: 127.0.0.1')
parser.add_argument('--port', default='8899', help='Default: 8899')
parser.add_argument('--backlog', default='100', help='Default: 100. '
'Maximum number of pending connections to proxy server')
parser.add_argument('--basic-auth', default=None, help='Default: No authentication. '
'Specify colon separated user:password '
'to enable basic authentication.')
parser.add_argument('--server-recvbuf-size', default='8192', help='Default: 8 KB. '
'Maximum amount of data received from the '
'server in a single recv() operation. Bump this '
'value for faster downloads at the expense of '
'increased RAM.')
parser.add_argument('--client-recvbuf-size', default='8192', help='Default: 8 KB. '
'Maximum amount of data received from the '
'client in a single recv() operation. Bump this '
'value for faster uploads at the expense of '
'increased RAM.')
parser.add_argument('--open-file-limit', default='1024', help='Default: 1024. '
'Maximum number of files (TCP connections) '
'that proxy.py can open concurrently.')
parser.add_argument('--log-level', default='INFO', help='DEBUG, INFO (default), WARNING, ERROR, CRITICAL')
parser.add_argument('--host_resolver', default=None, help='Default: No host resolution. '
'JSON hosts file used for hostname IP resolution.')
args = parser.parse_args()
logging.basicConfig(level=getattr(logging, args.log_level),
format='%(asctime)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s')
try:
set_open_file_limit(int(args.open_file_limit))
auth_code = None
if args.basic_auth:
auth_code = b'Basic %s' % base64.b64encode(bytes_(args.basic_auth))
host_resolver = None
if args.host_resolver:
with open(args.host_resolver) as json_file:
host_resolver = json.load(json_file)
proxy = HTTP(hostname=args.hostname,
port=int(args.port),
backlog=int(args.backlog),
auth_code=auth_code,
server_recvbuf_size=int(args.server_recvbuf_size),
client_recvbuf_size=int(args.client_recvbuf_size),
host_resolver=host_resolver)
proxy.run()
except KeyboardInterrupt:
pass
if __name__ == '__main__':
main()