|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +import os, sys, socket, logging, inspect, random, signal, asyncio, contextlib as cl |
| 4 | + |
| 5 | + |
| 6 | +err_fmt = lambda err: f'[{err.__class__.__name__}] {err}' |
| 7 | + |
| 8 | +class HTTPSFragProxy: |
| 9 | + |
| 10 | + buff_sz = 1500 |
| 11 | + |
| 12 | + def __init__(self, host, port, sni_list=None, af=0): |
| 13 | + self.bind, self.log = (host, port, af), logging.getLogger('nhp.proxy') |
| 14 | + if sni_list is not None: |
| 15 | + if not sni_list: sni_list = [b'\0'] # match nothing |
| 16 | + else: sni_list = list(s.strip().encode() for s in sni_list) |
| 17 | + self.snis = sni_list |
| 18 | + |
| 19 | + async def run(self): |
| 20 | + (host, port, af), loop = self.bind, asyncio.get_running_loop() |
| 21 | + for sig in signal.SIGINT, signal.SIGTERM: # asyncio.start_server hangs |
| 22 | + loop.add_signal_handler(sig, lambda: os._exit(0)) |
| 23 | + server = await asyncio.start_server(self.conn_handle, host, port, family=af) |
| 24 | + await server.serve_forever() |
| 25 | + |
| 26 | + async def conn_wrap(self, func, reader, writer, close_writer=False): |
| 27 | + try: await func(reader, writer) |
| 28 | + except Exception as err: |
| 29 | + writer.close() |
| 30 | + self.log.exception('Failed handing connection: %s', err_fmt(err)) |
| 31 | + finally: |
| 32 | + if close_writer: writer.close() |
| 33 | + |
| 34 | + def conn_handle(self, reader, writer): |
| 35 | + return self.conn_wrap(self._conn_handle, reader, writer) |
| 36 | + async def _conn_handle(self, reader, writer): |
| 37 | + cid = ':'.join(map(str, writer.get_extra_info('peername'))) |
| 38 | + http_data = await reader.read(self.buff_sz) |
| 39 | + if not http_data: writer.close(); return |
| 40 | + method, url = (headers := http_data.split(b'\r\n'))[0].decode().split()[:2] |
| 41 | + self.log.debug( 'Connection [%s] ::' |
| 42 | + ' %s %s [buff=%d]', cid, method, url, len(http_data) ) |
| 43 | + |
| 44 | + if https := (method == 'CONNECT'): # https |
| 45 | + host, _, port = url.partition(':') |
| 46 | + port = int(port) if port else 443 |
| 47 | + else: # paintext http |
| 48 | + host_header = next((h for h in headers if h.startswith(b'Host: ')), None) |
| 49 | + if not host_header: raise ValueError('Missing Host header') |
| 50 | + host, _, port = host_header[6:].partition(b':') |
| 51 | + host, port = host.decode(), int(port) if port else 80 |
| 52 | + |
| 53 | + try: xreader, xwriter = await asyncio.open_connection(host, port) |
| 54 | + except OSError as err: return self.log.info( 'Connection [%s]' |
| 55 | + ' to %s:%s failed (tls=%s): %s', cid, host, port, int(https), err_fmt(err) ) |
| 56 | + if not https: xwriter.write(http_data); await xwriter.drain() |
| 57 | + else: |
| 58 | + writer.write(b'HTTP/1.1 200 Connection Established\r\n\r\n') |
| 59 | + await writer.drain() |
| 60 | + await self.conn_wrap(self.fragment_data, reader, xwriter) |
| 61 | + |
| 62 | + for task in asyncio.as_completed([ |
| 63 | + self.conn_wrap(self.pipe, reader, xwriter, True), |
| 64 | + self.conn_wrap(self.pipe, xreader, writer, True) ]): |
| 65 | + await task |
| 66 | + |
| 67 | + async def fragment_data(self, reader, writer): |
| 68 | + head = await reader.read(5) |
| 69 | + data = await reader.read(2048) |
| 70 | + if self.snis and all(site not in data for site in self.snis): |
| 71 | + writer.write(head); writer.write(data); await writer.drain(); return |
| 72 | + parts, host_end = list(), data.find(b'\x00') |
| 73 | + if host_end != -1: |
| 74 | + parts.append( b'\x16\x03\x04' + |
| 75 | + (host_end + 1).to_bytes(2, 'big') + data[:host_end + 1] ) |
| 76 | + data = data[host_end + 1:] |
| 77 | + while data: |
| 78 | + chunk_len = random.randint(1, len(data)) |
| 79 | + parts.append( b'\x16\x03\x04' + |
| 80 | + chunk_len.to_bytes(2, 'big') + data[:chunk_len] ) |
| 81 | + data = data[chunk_len:] |
| 82 | + writer.write(b''.join(parts)); await writer.drain() |
| 83 | + |
| 84 | + async def pipe(self, reader, writer): |
| 85 | + while not reader.at_eof() and not writer.is_closing(): |
| 86 | + data = await reader.read(self.buff_sz); writer.write(data); await writer.drain() |
| 87 | + |
| 88 | + |
| 89 | +def main(args=None): |
| 90 | + import argparse, textwrap, re |
| 91 | + dd = lambda text: re.sub( r' \t+', ' ', |
| 92 | + textwrap.dedent(text).strip('\n') + '\n' ).replace('\t', ' ') |
| 93 | + parser = argparse.ArgumentParser( |
| 94 | + formatter_class=argparse.RawTextHelpFormatter, usage='%(prog)s [opts] [lines]', |
| 95 | + description=dd(''' |
| 96 | + HTTP-proxy for TLS connections that detects specific hosts in SNI fields |
| 97 | + and fragments those to avoid/confuse deep packet inspection (DPI) systems. |
| 98 | + Despite the name, it should be set as "HTTP Proxy" in e.g. browsers, NOT HTTPS one. |
| 99 | + Based on https://github.com/GVCoder09/NoDPI distributed under GPL v3 license.''')) |
| 100 | + parser.add_argument('-i', '--bind', |
| 101 | + metavar='host[:port]', default='127.0.0.1:8101', help=dd(''' |
| 102 | + Address/host and port to bind proxy server socket to (default: %(default)s). |
| 103 | + It should be used and accessible with e.g. http_proxy= env-var.''')) |
| 104 | + parser.add_argument('-d', '--frag-domains', metavar='file', help=dd(''' |
| 105 | + File with a list of space/newline-separated domains |
| 106 | + to detect in TLS handshakes and fragment connection data for. |
| 107 | + "-" can be used for stdin, or %%-prefixed number for any file descriptor (e.g. %%3). |
| 108 | + Default is to apply such fragmentation to all processed connections.''')) |
| 109 | + parser.add_argument('--debug', action='store_true', help='Verbose logging to stderr.') |
| 110 | + opts = parser.parse_args(sys.argv[1:] if args is None else args) |
| 111 | + |
| 112 | + @cl.contextmanager |
| 113 | + def in_file(path): |
| 114 | + if not path or path == '-': return (yield sys.stdin) |
| 115 | + if path[0] == '%': path = int(path[1:]) |
| 116 | + with open(path) as src: yield src |
| 117 | + |
| 118 | + logging.basicConfig( format='%(levelname)s :: %(message)s', |
| 119 | + level=logging.WARNING if not opts.debug else logging.DEBUG ) |
| 120 | + log = logging.getLogger('nhp.main') |
| 121 | + |
| 122 | + sni_list = None |
| 123 | + if opts.frag_domains: |
| 124 | + with in_file(opts.frag_domains) as src: sni_list = src.read().split() |
| 125 | + |
| 126 | + host, _, port = opts.bind.partition(':'); port = int(port or 8101) |
| 127 | + proxy = HTTPSFragProxy(host, port, sni_list) |
| 128 | + log.debug( 'Starting proxy (%s)...', |
| 129 | + f'{len(sni_list):,d} SNI domains' if sni_list is not None else 'fragmenting any SNI' ) |
| 130 | + return asyncio.run(proxy.run()) |
| 131 | + |
| 132 | +if __name__ == '__main__': sys.exit(main()) |
0 commit comments