Skip to content

Commit 7cd70d5

Browse files
committed
+nodpi-https-proxy - simpler version of GVCoder09/NoDPI
1 parent dfef44e commit 7cd70d5

File tree

2 files changed

+145
-0
lines changed

2 files changed

+145
-0
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ Contents - links to doc section for each script here:
112112
- [svg-tweak](#hdr-svg-tweak)
113113
- [unix-socket-links](#hdr-unix-socket-links)
114114
- [tcpdump-translate](#hdr-tcpdump-translate)
115+
- [nodpi-https-proxy](#hdr-nodpi-https-proxy)
115116

116117
- [\[dev\] Dev tools](#hdr-dev___dev_tools)
117118

@@ -2583,6 +2584,18 @@ dropped" type of simple connectivity issues in-between running pings and whateve
25832584
configuration tweaks.
25842585
Not a replacement for wireshark or tcpdump firehose-filters.
25852586

2587+
<a name=hdr-nodpi-https-proxy></a>
2588+
##### [nodpi-https-proxy](nodpi-https-proxy)
2589+
2590+
Simpler ~100-line version of [GVCoder09/NoDPI] http-proxy script,
2591+
which fragments https requests where it detects SNI that won't be
2592+
allowed through by some censorshit DPI otherwise.
2593+
2594+
Rewritten to not have as much needless stats, boilerplate, verbosity
2595+
and cross-platform cruft, to make easier adjustments for misc local needs.
2596+
2597+
[GVCoder09/NoDPI]: https://github.com/GVCoder09/NoDPI
2598+
25862599

25872600

25882601
<a name=hdr-dev___dev_tools></a>

nodpi-https-proxy

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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

Comments
 (0)