privoxy-tls/proxy.py

389 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"HTTP Proxy utilities, pyOpenSSL version"
__author__ = 'phoenix'
__version__ = '1.0'
from datetime import datetime
import logging
import threading
import cgi
import socket
import select
import ssl
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from cert import get_cert
_name = 'proxy'
logger = logging.getLogger('__main__')
message_format = '''\
<!doctype html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
<title>Proxy Error: {code}</title>
</head>
<body>
<h1>{code}: {message}</h1>
<p>The following error occurred while
trying to access <strong>{url}</strong>
</p>
<p><strong>{explain}</strong></p>
<hr>Generated on {now} by {server}.
</body>
</html>
'''
def read_write(socket1, socket2, max_idling=10):
'''
Read and Write contents between 2 sockets
'''
iw = [socket1, socket2]
ow = []
count = 0
while True:
count += 1
(ins, _, exs) = select.select(iw, ow, iw, 1)
if exs:
break
if ins:
for reader in ins:
writer = socket2 if reader is socket1 else socket1
try:
data = reader.recv(1024)
if data:
writer.send(data)
count = 0
except (ConnectionAbortedError,
ConnectionResetError,
BrokenPipeError):
pass
if count == max_idling:
break
class Counter:
reset_value = 999
def __init__(self, start=0):
self.lock = threading.Lock()
self.value = start
def increment_and_set(self, obj, attr):
with self.lock:
self.value = self.value + 1 if self.value < self.reset_value else 1
setattr(obj, attr, self.value)
class ProxyRequestHandler(BaseHTTPRequestHandler):
'''
RequestHandler with do_CONNECT method defined
'''
server_version = f'{_name}/{__version__}'
# do_CONNECT() will set self.ssltunnel to override this
ssltunnel = False
# Override default value 'HTTP/1.0'
protocol_version = 'HTTP/1.1'
# To be set in each request
reqNum = 0
def do_CONNECT(self):
'''
Descrypt https request and dispatch to http handler
'''
# request line: CONNECT www.example.com:443 HTTP/1.1
self.host, self.port = self.path.split(':')
# SSL MITM
self.wfile.write(
('HTTP/1.1 200 Connection established\r\n'
+ f'Proxy-agent: {self.version_string()}\r\n'
+ '\r\n').encode('ascii'))
if self.host.count('.') >= 2:
commonname = '.' + self.host.partition('.')[-1]
else:
commonname = self.host
dummycert = get_cert(commonname, self.config)
# set a flag for do_METHOD
self.ssltunnel = True
ssl_sock = ssl.wrap_socket(self.connection, keyfile=dummycert,
certfile=dummycert, server_side=True)
# Ref: Lib/socketserver.py#StreamRequestHandler.setup()
self.connection = ssl_sock
self.rfile = self.connection.makefile('rb', self.rbufsize)
self.wfile = self.connection.makefile('wb', self.wbufsize)
# dispatch to do_METHOD()
self.handle_one_request()
def handle_one_request(self):
'''Catch more exceptions than default
Intend to catch exceptions on local side
Exceptions on remote side should be handled in do_*()
'''
try:
BaseHTTPRequestHandler.handle_one_request(self)
return
except (ConnectionError, FileNotFoundError) as e:
logger.warning('{:03d} {} {}'.format(
self.reqNum, self.server_version, e))
except (ssl.SSLEOFError, ssl.SSLError) as e:
if hasattr(self, 'url'):
# Happens after the tunnel is established
logger.warning(f'{self.reqNum:03d} "{e}" while operating'
f' on established local SSL tunnel for'
f' [{self.url}]')
else:
logger.warning(f'{self.reqNum:03d} "{e}" while trying to'
' establish local SSL tunnel for'
f'[{self.path}]')
self.close_connection = 1
def sendout_error(self, url, code, message=None, explain=None):
'''
Modified from http.server.send_error() for customized display
'''
try:
shortmsg, longmsg = self.responses[code]
except KeyError:
shortmsg, longmsg = '???', '???'
if message is None:
message = shortmsg
if explain is None:
explain = longmsg
content = message_format.format(
code=code, message=message,
explain=explain, url=url,
now=datetime.today(),
server=self.server_version)
body = content.encode('UTF-8', 'replace')
self.send_response_only(code, message)
self.send_header('Content-Type', self.error_content_type)
self.send_header('Content-Length', int(len(body)))
self.end_headers()
if self.command != 'HEAD' and code >= 200 and code not in (204, 304):
self.wfile.write(body)
def deny_request(self):
self.send_response_only(403)
self.send_header('Content-Length', 0)
self.end_headers()
def redirect(self, url):
self.send_response_only(302)
self.send_header('Content-Length', 0)
self.send_header('Location', url)
self.end_headers()
def forward_to_https_proxy(self):
'''
Forward https request to upstream https proxy
'''
logger.debug(f'Using Proxy - {self.proxy}')
proxy_host, proxy_port = self.proxy.split('//')[1].split(':')
server_conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
server_conn.connect((proxy_host, int(proxy_port)))
server_conn.send((
f'CONNECT {self.path} HTTP/1.1\r\n\r\n').encode('ascii'))
server_conn.settimeout(0.1)
datas = b''
while True:
try:
data = server_conn.recv(4096)
except socket.timeout:
break
if data:
datas += data
else:
break
server_conn.setblocking(True)
if b'200' in datas and b'established' in datas.lower():
logger.info('{:03d} [P] SSL Pass-Thru: https://{}/'.format(
self.reqNum, self.path))
self.wfile.write(('HTTP/1.1 200 Connection established\r\n' +
'Proxy-agent: {}\r\n\r\n'.format(
self.version_string()).encode('ascii')))
read_write(self.connection, server_conn)
else:
logger.warning('{:03d} Proxy {} failed.'.format(
self.reqNum, self.proxy))
if datas:
logger.debug(datas)
self.wfile.write(datas)
finally:
# We don't maintain a connection reuse pool,
# so close the connection anyway
server_conn.close()
def forward_to_socks5_proxy(self):
'''
Forward https request to upstream socks5 proxy
'''
logger.warning('Socks5 proxy not implemented yet, '
'please use https proxy')
def tunnel_traffic(self):
'''
Tunnel traffic to remote host:port
'''
logger.info('{:03d} [D] SSL Pass-Thru: https://{}/'.format(
self.reqNum, self.path))
server_conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
server_conn.connect((self.host, int(self.port)))
self.wfile.write(('HTTP/1.1 200 Connection established\r\n'
+ 'Proxy-agent: {}\r\n'.format(
self.version_string())
+ '\r\n').encode('ascii'))
read_write(self.connection, server_conn)
except TimeoutError:
self.wfile.write(b'HTTP/1.1 504 Gateway Timeout\r\n\r\n')
logger.warning('{:03d} Timed Out: https://{}:{}/'.format(
self.reqNum, self.host, self.port))
except socket.gaierror as e:
self.wfile.write(b'HTTP/1.1 503 Service Unavailable\r\n\r\n')
logger.warning('{:03d} {}: https://{}:{}/'.format(
self.reqNum, e, self.host, self.port))
finally:
# We don't maintain a connection reuse pool,
# so close the connection anyway
server_conn.close()
def ssl_get_response(self, conn):
try:
server_conn = ssl.wrap_socket(
conn, cert_reqs=ssl.CERT_REQUIRED,
ca_certs="cacert.pem",
ssl_version=ssl.PROTOCOL_TLSv1)
server_conn.sendall(
('{} {} HTTP/1.1\r\n'.format(
self.command, self.path)).encode('ascii'))
server_conn.sendall(self.headers.as_bytes())
if self.postdata:
server_conn.sendall(self.postdata)
while True:
data = server_conn.recv(4096)
if data:
self.wfile.write(data)
else:
break
except (ssl.SSLEOFError, ssl.SSLError) as e:
logger.error("[SSLError]")
self.send_error(417, message="Exception "
+ str(e.__class__), explain=str(e))
def purge_headers(self, headers):
'''
Remove hop-by-hop headers that shouldn't pass through a Proxy
'''
for name in ['Connection', 'Keep-Alive', 'Upgrade',
'Proxy-Connection', 'Proxy-Authenticate']:
del headers[name]
def purge_write_headers(self, headers):
self.purge_headers(headers)
for key, value in headers.items():
self.send_header(key, value)
self.end_headers()
def stream_to_client(self, response):
bufsize = 1024 * 64
need_chunked = 'Transfer-Encoding' in response.headers
written = 0
while True:
data = response.read(bufsize)
if not data:
if need_chunked:
self.wfile.write(b'0\r\n\r\n')
break
if need_chunked:
self.wfile.write((f'{len(data):x}\r\n').encode('ascii'))
self.wfile.write(data)
if need_chunked:
self.wfile.write(b'\r\n')
written += len(data)
return written
def http_request_info(self):
'''
Return HTTP request information in bytes
'''
context = ['CLIENT VALUES:',
f'client_address = {self.client_address}',
f'requestline = {self.requestline}',
f'command = {self.command}',
f'path = {self.path}',
f'request_version = {self.request_version}',
'',
'SERVER VALUES:',
'server_version = {self.server_version}',
'sys_version = {self.sys_version}',
'protocol_version = {self.protocol_version}',
'',
'HEADER RECEIVED:']
for name, value in sorted(self.headers.items()):
context.append(f'{name} = {value.rstrip()}')
if self.command == 'POST':
context.append('\r\nPOST VALUES:')
form = cgi.FieldStorage(fp=self.rfile,
headers=self.headers,
environ={'REQUEST_METHOD': 'POST'})
for field in form.keys():
fielditem = form[field]
if fielditem.filename:
# The field contains an uploaded file
file_data = fielditem.file.read()
file_len = len(file_data)
context.append('Uploaded {} as "{}" ({} bytes)'.format(
field, fielditem.filename, file_len))
else:
# Regular form value
context.append(f'{field} = {fielditem.value}')
return('\r\n'.join(context).encode('ascii'))
def demo():
PORT = 8000
class ProxyServer(ThreadingMixIn, HTTPServer):
'''Handle requests in a separate thread.'''
pass
class RequestHandler(ProxyRequestHandler):
'Displaying HTTP request information'
server_version = 'DemoProxy/0.1'
def do_METHOD(self):
'Universal method for GET, POST, HEAD, PUT and DELETE'
message = self.http_request_info()
self.send_response(200)
# 'Content-Length' is important for HTTP/1.1
self.send_header('Content-Length', len(message))
self.end_headers()
self.wfile.write(message)
do_GET = do_POST = do_HEAD = do_METHOD
do_PUT = do_DELETE = do_OPTIONS = do_METHOD
print(RequestHandler.server_version, 'serving now, <Ctrl-C> to stop ...')
print(f'Listen Addr : localhost:{PORT}')
print('-' * 10)
server = ProxyServer(('', PORT), RequestHandler)
server.serve_forever()
if __name__ == '__main__':
try:
demo()
except KeyboardInterrupt:
print('Quitting...')