From b49bc266f505d0e1d733fb4d4efd9ad7c793248c Mon Sep 17 00:00:00 2001 From: John-Mark Gurney Date: Sat, 11 Jun 2022 20:55:05 -0700 Subject: [PATCH] add optional quic support... --- Makefile | 6 +- ntunnel/__init__.py | 6 ++ ntunnel/quic.py | 150 ++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + setup.py | 1 + 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 ntunnel/quic.py diff --git a/Makefile b/Makefile index de4de2c..177b21b 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,12 @@ VIRTUALENV ?= python3 -m venv VRITUALENVARGS = -FILES=ntunnel/__init__.py +FILES=ntunnel/*.py + +MODULES=ntunnel ntunnel.quic test: - (echo $(FILES) | entr sh -c 'python -m coverage run -m unittest ntunnel && coverage report --omit=p/\* -m -i') + (ls $(FILES) | entr sh -c 'python -m coverage run -m unittest $(MODULES) && coverage report --omit=p/\* -m -i') test-noentr: python -m coverage run -m unittest ntunnel && coverage report --omit=p/\* -m -i diff --git a/ntunnel/__init__.py b/ntunnel/__init__.py index 714a3af..ba48d52 100644 --- a/ntunnel/__init__.py +++ b/ntunnel/__init__.py @@ -734,6 +734,12 @@ def main(): parser_client.add_argument('clienttarget', type=str, help='Connection that the client connects to') parser_client.set_defaults(func=cmd_client) + try: + from .quic import quic_parsers + quic_parsers(subparsers) + except ImportError: + parser.epilog = 'The QUIC module is not available. Likely because the quic variant was not selected/installed.' + args = parser.parse_args() try: diff --git a/ntunnel/quic.py b/ntunnel/quic.py new file mode 100644 index 0000000..15bb04a --- /dev/null +++ b/ntunnel/quic.py @@ -0,0 +1,150 @@ +# Copyright 2022 John-Mark Gurney. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# + +import asyncio +import unittest + +from . import parsesockstr, connectsockstr, listensockstr + +from aioquic.asyncio import QuicConnectionProtocol, serve +from aioquic.asyncio.client import connect +from aioquic.quic.configuration import QuicConfiguration + +# copied from wsfwd +async def fwd_data(reader, writer): + while True: + data = await reader.read(16384) + if data == b'': + #_debprint('fwd_data eof', repr(reader), repr(writer)) + # XXX - aioquic doesn't implement close + #writer.close() + #await writer.wait_closed() + #_debprint('fwd_data done', repr(reader), repr(writer)) + return + + #_debprint('fwd_data data', repr(reader), repr(writer), len(data)) + writer.write(data) + # XXX - aioquic doesn't implement is_closing + #await writer.drain() + +async def run_connect(dst, rdr, wrr): + connrdr, connwrr = await connectsockstr(dst) + + await asyncio.gather(fwd_data(connrdr, wrr), fwd_data(rdr, connwrr)) + +def cmd_quic_serv(args): + privkey = args.servkey + cert = args.cert + + quic_logger = None + + quic_conf = QuicConfiguration( + alpn_protocols=["ntunnel-01"], + is_client=False, + quic_logger=quic_logger, + ) + + quic_conf.load_cert_chain(cert, privkey) + + proto, slargs = parsesockstr(args.servlisten) + + if proto != 'udp': + raise ValueError('protocol for servlisten must be udp') + + def sh(rdr, wrr): + task = run_connect(args.servtarget, rdr, wrr) + asyncio.create_task(task) + + # XXX - await task + + print('foo', repr(slargs)) + + loop = asyncio.get_event_loop() + loop.run_until_complete(serve(slargs['host'], slargs['port'], + configuration=quic_conf, retry=True, stream_handler=sh)) + + try: + loop.run_forever() + except KeyboardInterrupt: + pass + +async def client_run(conf, liststr, deststr): + proto, slargs = parsesockstr(deststr) + + if proto != 'udp': + raise ValueError('protocol for destination must be udp') + + # XXX - loop when server restarts? + async with connect(slargs['host'], slargs['port'], configuration=conf) as \ + client: + async def connmaker(rdr, wrr): + connrdr, connwrr = await client.create_stream() + + await asyncio.gather(fwd_data(connrdr, wrr), + fwd_data(rdr, connwrr)) + + ssock = await listensockstr(liststr, connmaker) + + # XXX - how to break out when new connection needed? + await ssock.serve_forever() + +def cmd_quic_client(args): + quic_logger = None + + quic_conf = QuicConfiguration( + alpn_protocols=["ntunnel-01"], + is_client=True, + quic_logger=quic_logger, + ) + + if args.ca_certs: + quic_conf.load_verify_locations(args.ca_certs) + + cr = client_run(quic_conf, args.clientlisten, args.clienttarget) + + loop = asyncio.get_event_loop() + + loop.run_until_complete(cr) + + +def quic_parsers(subparsers): + parser_quic_serv = subparsers.add_parser('quic_serv', help='run a QUIC server') + parser_quic_serv.add_argument("-k", "--servkey", type=str, + help="load the TLS private key from the specified file") + parser_quic_serv.add_argument("-c", "--cert", type=str, + required=True, + help="load the TLS certificate from the specified file") + parser_quic_serv.add_argument('servlisten', type=str, help='Connection that the server listens on') + parser_quic_serv.add_argument('servtarget', type=str, help='Connection that the server connects to') + parser_quic_serv.set_defaults(func=cmd_quic_serv) + + parser_quic_client = subparsers.add_parser('quic_client', help='run a QUIC client') + parser_quic_client.add_argument("--ca-certs", type=str, + help="load CA certificates from the specified file") + parser_quic_client.add_argument('clientlisten', type=str, help='Connection that the client listens on') + parser_quic_client.add_argument('clienttarget', type=str, help='Connection that the client connects to') + parser_quic_client.set_defaults(func=cmd_quic_client) + +class Tests(unittest.IsolatedAsyncioTestCase): + pass diff --git a/requirements.txt b/requirements.txt index c518e6b..cfc3392 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ -e . -e .[dev] +-e .[quic] diff --git a/setup.py b/setup.py index 401489f..71b7afd 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ setup(name='ntunnel', ], extras_require = { 'dev': [ 'coverage' ], + 'quic': [ 'aioquic' ], }, entry_points={ 'console_scripts': [