Skip to content

Commit 7dbfbcd

Browse files
committed
nodpi-https-proxy: add systemd socket activation support
1 parent 7cd70d5 commit 7dbfbcd

File tree

2 files changed

+51
-12
lines changed

2 files changed

+51
-12
lines changed

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2592,7 +2592,22 @@ which fragments https requests where it detects SNI that won't be
25922592
allowed through by some censorshit DPI otherwise.
25932593

25942594
Rewritten to not have as much needless stats, boilerplate, verbosity
2595-
and cross-platform cruft, to make easier adjustments for misc local needs.
2595+
and cross-platform cruft, to make easier adjustments, e.g. to start/stop
2596+
as-needed in systemd user session:
2597+
2598+
``` ini
2599+
[Socket]
2600+
ListenStream=127.0.0.1:8101
2601+
2602+
[Install]
2603+
WantedBy=sockets.target
2604+
```
2605+
2606+
``` ini
2607+
[Service]
2608+
Type=exec
2609+
ExecStart=nodpi-https-proxy -t 600
2610+
```
25962611

25972612
[GVCoder09/NoDPI]: https://github.com/GVCoder09/NoDPI
25982613

nodpi-https-proxy

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,30 @@ class HTTPSFragProxy:
99

1010
buff_sz = 1500
1111

12-
def __init__(self, host, port, sni_list=None, af=0):
13-
self.bind, self.log = (host, port, af), logging.getLogger('nhp.proxy')
12+
def __init__(self, sni_list=None, idle_timeout=None, **bind):
13+
self.idle_timeout, self.bind = idle_timeout, bind
1414
if sni_list is not None:
1515
if not sni_list: sni_list = [b'\0'] # match nothing
1616
else: sni_list = list(s.strip().encode() for s in sni_list)
17-
self.snis = sni_list
17+
self.snis, self.log = sni_list, logging.getLogger('nhp.proxy')
1818

1919
async def run(self):
20-
(host, port, af), loop = self.bind, asyncio.get_running_loop()
20+
loop, self.activity = asyncio.get_running_loop(), asyncio.Event()
2121
for sig in signal.SIGINT, signal.SIGTERM: # asyncio.start_server hangs
2222
loop.add_signal_handler(sig, lambda: os._exit(0))
23-
server = await asyncio.start_server(self.conn_handle, host, port, family=af)
23+
if self.idle_timeout:
24+
asyncio.create_task(self.idle_stop(self.idle_timeout))
25+
server = await asyncio.start_server(self.conn_handle, **self.bind)
2426
await server.serve_forever()
2527

28+
async def idle_stop(self, timeout):
29+
while True:
30+
try: await asyncio.wait_for(self.activity.wait(), timeout)
31+
except TimeoutError:
32+
self.log.debug('Exiting on timeout (%.1fs)', timeout)
33+
return os.kill(os.getpid(), signal.SIGTERM)
34+
self.activity.clear()
35+
2636
async def conn_wrap(self, func, reader, writer, close_writer=False):
2737
try: await func(reader, writer)
2838
except Exception as err:
@@ -34,6 +44,7 @@ class HTTPSFragProxy:
3444
def conn_handle(self, reader, writer):
3545
return self.conn_wrap(self._conn_handle, reader, writer)
3646
async def _conn_handle(self, reader, writer):
47+
self.activity.set()
3748
cid = ':'.join(map(str, writer.get_extra_info('peername')))
3849
http_data = await reader.read(self.buff_sz)
3950
if not http_data: writer.close(); return
@@ -53,6 +64,7 @@ class HTTPSFragProxy:
5364
try: xreader, xwriter = await asyncio.open_connection(host, port)
5465
except OSError as err: return self.log.info( 'Connection [%s]'
5566
' to %s:%s failed (tls=%s): %s', cid, host, port, int(https), err_fmt(err) )
67+
self.activity.set()
5668
if not https: xwriter.write(http_data); await xwriter.drain()
5769
else:
5870
writer.write(b'HTTP/1.1 200 Connection Established\r\n\r\n')
@@ -83,29 +95,32 @@ class HTTPSFragProxy:
8395

8496
async def pipe(self, reader, writer):
8597
while not reader.at_eof() and not writer.is_closing():
98+
self.activity.set()
8699
data = await reader.read(self.buff_sz); writer.write(data); await writer.drain()
87100

88101

89102
def main(args=None):
90103
import argparse, textwrap, re
91104
dd = lambda text: re.sub( r' \t+', ' ',
92105
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('''
106+
parser = argparse.ArgumentParser(usage='%(prog)s [options]',
107+
formatter_class=argparse.RawTextHelpFormatter, description=dd('''
96108
HTTP-proxy for TLS connections that detects specific hosts in SNI fields
97109
and fragments those to avoid/confuse deep packet inspection (DPI) systems.
98110
Despite the name, it should be set as "HTTP Proxy" in e.g. browsers, NOT HTTPS one.
99111
Based on https://github.com/GVCoder09/NoDPI distributed under GPL v3 license.'''))
100112
parser.add_argument('-i', '--bind',
101113
metavar='host[:port]', default='127.0.0.1:8101', help=dd('''
102114
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.'''))
115+
It should be used and accessible with e.g. http_proxy= env-var.
116+
Ignored if process is started by systemd from a socket unit.'''))
104117
parser.add_argument('-d', '--frag-domains', metavar='file', help=dd('''
105118
File with a list of space/newline-separated domains
106119
to detect in TLS handshakes and fragment connection data for.
107120
"-" can be used for stdin, or %%-prefixed number for any file descriptor (e.g. %%3).
108121
Default is to apply such fragmentation to all processed connections.'''))
122+
parser.add_argument('-t', '--idle-timeout', type=float, metavar='seconds', help=dd('''
123+
Stop after number of seconds being idle. Useful if started from systemd socket.'''))
109124
parser.add_argument('--debug', action='store_true', help='Verbose logging to stderr.')
110125
opts = parser.parse_args(sys.argv[1:] if args is None else args)
111126

@@ -123,8 +138,17 @@ def main(args=None):
123138
if opts.frag_domains:
124139
with in_file(opts.frag_domains) as src: sni_list = src.read().split()
125140

126-
host, _, port = opts.bind.partition(':'); port = int(port or 8101)
127-
proxy = HTTPSFragProxy(host, port, sni_list)
141+
sd_pid, sd_fds = (int(os.environ.get(f'LISTEN_{k}', 0)) for k in ['PID', 'FDS'])
142+
if sd_pid == os.getpid() and sd_fds:
143+
if sd_fds > 1: return log.error( 'More than one socket passed'
144+
' from systemd, exiting (pid=%s fds=%s)', sd_pid, sd_fds ) or 1
145+
log.debug('Listening on socket fd=3 received from systemd')
146+
proxy = dict(sock=socket.socket(fileno=3))
147+
else:
148+
host, _, port = opts.bind.partition(':'); port = int(port or 8101)
149+
proxy = dict(host=host, port=port)
150+
proxy = HTTPSFragProxy(sni_list=sni_list, idle_timeout=opts.idle_timeout, **proxy)
151+
128152
log.debug( 'Starting proxy (%s)...',
129153
f'{len(sni_list):,d} SNI domains' if sni_list is not None else 'fragmenting any SNI' )
130154
return asyncio.run(proxy.run())

0 commit comments

Comments
 (0)