test_tunnel-0.1.1/.readthedocs.yaml0000644000000000000000000000043213615410400014223 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause version: 2 build: os: ubuntu-22.04 tools: python: "3.12" mkdocs: configuration: mkdocs.yml python: install: - requirements: requirements/docs.txt test_tunnel-0.1.1/README.md0000644000000000000000000000741113615410400012257 0ustar00 # The test-tunnel library: write tests for network tunnelling utilities \[[Home][ringlet-home] | [GitLab][gitlab] | [Download][ringlet-download] | [PyPI][pypi] | [ReadTheDocs][readthedocs]\] ## Overview The `test-tunnel` library's purpose is to make it easy to write either command-line tools or test modules that start some network tunnelling server (e.g. stunnel, microsocks, Dante) and verify that it does indeed forward connections and data as expected. ## A tunnel test scenario Test classes derived from the `test-tunnel` library's [TestTunnel][test_tunnel.run_test.TestTunnel] class have a [run()][test_tunnel.run_test.TestTunnel.run] method that performs the following actions: - examines the IPv4 and IPv6 network interfaces currently configured on the running system and picks two available ports to listen on for each one - makes a "possible connections" mapping, determining which of these addresses may be used as source and destination addresses for TCP connections. It is possible that some pairs are invalid either due to network protocol limitations or due to local system policy. - picks a set of (server, proxy as client, proxy as server, client) address/port combinations from the above mapping so that the client may connect to the proxy and the proxy, in turn, may connect to the server ## Writing a test class for a new tool To write a new test class it is enough to create a new Python class derived from [test_tunnel.run_test.TestTunnel][] and implement at least the three methods defined as abstract in that base class: - [slug()][test_tunnel.run_test.TestTunnel.slug]: return a short text string used in log messages to identify the tested program (e.g. "microsocks", "socat") - [do_spawn_server()][test_tunnel.run_test.TestTunnel.do_spawn_server]: start the tested tool with the specified address and port to listen on and address and port to forward connections to. This method may possibly prepare a configuration file if the tool needs it, or it may start the tool and pass the addresses and ports directly on the command line if supported. - [do_handshake()][test_tunnel.run_test.TestTunnel.do_handshake]: once a client socket has been connected to the already started tool (see the [do_spawn_server()][test_tunnel.run_test.TestTunnel.do_spawn_server] method), send and receive any "handshake" data required to make the tool establish a connection to the test listener started by the `test-tunnel` library itself. For a SOCKS5 server this should be the protocol negotiation and authentication, for an HTTP proxy server this would be the `CONNECT` request, etc. ## Example tools The `test-tunnel` library contains two example command-line tools that implement the test classes for two data forwarding programs: the [socat][test_tunnel.cmd_test.socat] multipurpose relay tool and the [microsocks][test_tunnel.cmd_test.microsocks] SOCKS5 server. They may serve as a starting point for writing new test classes. ## Contact The `test-tunnel` library was written by [Peter Pentchev][roam]. It is developed in [a GitLab repository][gitlab]. This documentation is hosted at [Ringlet][ringlet-home] with a copy at [ReadTheDocs][readthedocs]. [roam]: mailto:roam@ringlet.net "Peter Pentchev" [gitlab]: https://gitlab.com/ppentchev/test-tunnel "The test-tunnel GitLab repository" [pypi]: https://pypi.org/project/test-tunnel/ "The test-tunnel Python Package Index page" [readthedocs]: https://test-tunnel.readthedocs.io/ "The test-tunnel ReadTheDocs page" [ringlet-home]: https://devel.ringlet.net/net/test-tunnel/ "The Ringlet test-tunnel homepage" [ringlet-download]: https://devel.ringlet.net/net/test-tunnel/download/ "The Ringlet test-tunnel download homepage" test_tunnel-0.1.1/mkdocs.yml0000644000000000000000000000266413615410400013010 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause theme: name: material features: - navigation.instant - navigation.tracking - toc.integrate - toc.follow - content.code.copy palette: - media: "(prefers-color-scheme: light)" scheme: default toggle: icon: material/weather-sunny name: Switch to dark mode - media: "(prefers-color-scheme: dark)" scheme: slate toggle: icon: material/weather-night name: Switch to light mode site_name: test-tunnel repo_url: https://gitlab.com/ppentchev/test-tunnel repo_name: test-tunnel site_author: ppentchev site_url: https://devel.ringlet.net/net/test-tunnel/ site_dir: site/docs nav: - 'index.md' - 'Changelog': 'changes.md' - 'Download': 'download.md' - 'API reference': - 'api/index.md' - 'Common definitions': 'api/defs.md' - 'Network interface addresses': 'api/addresses.md' - 'Test runner infrastructure': 'api/run_test.md' - 'Sample implementations': 'api/cmd_test.md' markdown_extensions: - toc: - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite: - pymdownx.superfences: plugins: - mkdocstrings: default_handler: python handlers: python: paths: [src] options: heading_level: 3 show_root_heading: true - search watch: - 'src/test_tunnel' test_tunnel-0.1.1/ruff-base.toml0000644000000000000000000000151613615410400013547 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause target-version = "py310" line-length = 100 [lint] select = [] ignore = [ # No blank lines before the class docstring, TYVM "D203", # The multi-line docstring summary starts on the same line "D213", # We do not document everything in the docstrings "DOC201", "DOC501", # We want to provide as much context as possible in exceptions, # including the values of the loop variables "PERF203", # The "could be a function" check does not really work with class hierarchies "PLR6301", ] [lint.flake8-copyright] notice-rgx = "(?x) SPDX-FileCopyrightText: \\s \\S" [lint.isort] force-single-line = true known-first-party = ["test_tunnel"] lines-after-imports = 2 single-line-exclusions = ["typing"] [lint.per-file-ignores] test_tunnel-0.1.1/tox.ini0000644000000000000000000000316713615410400012317 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause [tox] envlist = docs ruff format mypy reuse functional-socat functional-microsocks isolated_build = True [defs] pyfiles = src/test_tunnel [testenv:ruff] tags = check quick deps = -r requirements/ruff.txt skip_install = True commands = ruff check {[defs]pyfiles} [testenv:format] skip_install = True tags = check quick deps = -r requirements/ruff.txt commands = ruff check --config ruff-base.toml --select=I --diff -- {[defs]pyfiles} ruff format --check --config ruff-base.toml --diff -- {[defs]pyfiles} [testenv:reformat] skip_install = True tags = format manual deps = -r requirements/ruff.txt commands = ruff check --config ruff-base.toml --select=I --fix -- {[defs]pyfiles} ruff format --config ruff-base.toml -- {[defs]pyfiles} [testenv:mypy] skip_install = True tags = check deps = -r requirements/install.txt mypy >= 1, < 2 setenv = MYPYPATH={toxinidir}/stubs commands = mypy {[defs]pyfiles} [testenv:reuse] skip_install = True tags = check quick deps = reuse >= 4, < 5 commands = reuse lint [testenv:functional-socat] tags = tests deps = -r requirements/install.txt commands = python3 -u -m test_tunnel.cmd_test.socat -v -O 0 -p {env:TEST_SOCAT:/usr/bin/socat} [testenv:functional-microsocks] tags = tests deps = -r requirements/install.txt commands = python3 -u -m test_tunnel.cmd_test.microsocks -v -O 10000 -p {env:TEST_MICROSOCKS:/usr/bin/microsocks} [testenv:docs] skip_install = True tags = docs deps = -r requirements/docs.txt commands = mkdocs build test_tunnel-0.1.1/LICENSES/BSD-2-Clause.txt0000644000000000000000000000236313615410400014730 0ustar00Copyright (c) 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 COPYRIGHT HOLDERS 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 COPYRIGHT HOLDER 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. test_tunnel-0.1.1/docs/changes.md0000644000000000000000000000147113615410400013662 0ustar00 # Changelog All notable changes to the test-tunnel project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [0.1.1] - 2024-08-06 ### Additions - Documentation: - adapt the documentation index to add a `README.md` file ## [0.1.0] - 2024-08-06 ### Started - First public release. [Unreleased]: https://gitlab.com/ppentchev/test-tunnel/-/compare/release%2F0.1.1...main [0.1.1]: https://gitlab.com/ppentchev/test-tunnel/-/compare/release%2F0.1.0...release%2F0.1.1 [0.1.0]: https://gitlab.com/ppentchev/test-tunnel/-/tags/release%2F0.1.0 test_tunnel-0.1.1/docs/download.md0000644000000000000000000000253213615410400014060 0ustar00 # Download These are the released versions of [test-tunnel](index.md) available for download. ## [0.1.1] - 2024-08-06 ### Source tarball - [test_tunnel-0.1.1.tar.gz](https://devel.ringlet.net/files/net/test-tunnel/test_tunnel-0.1.1.tar.gz) (with [a PGP signature](https://devel.ringlet.net/files/net/test-tunnel/test_tunnel-0.1.1.tar.gz.asc)) ### Python wheel - [test_tunnel-0.1.1-py3-none-any.whl](https://devel.ringlet.net/files/net/test-tunnel/test_tunnel-0.1.1-py3-none-any.whl) (with [a PGP signature](https://devel.ringlet.net/files/net/test-tunnel/test_tunnel-0.1.1-py3-none-any.whl.asc)) ## [0.1.0] - 2024-08-06 ### Source tarball - [test_tunnel-0.1.0.tar.gz](https://devel.ringlet.net/files/net/test-tunnel/test_tunnel-0.1.0.tar.gz) (with [a PGP signature](https://devel.ringlet.net/files/net/test-tunnel/test_tunnel-0.1.0.tar.gz.asc)) ### Python wheel - [test_tunnel-0.1.0-py3-none-any.whl](https://devel.ringlet.net/files/net/test-tunnel/test_tunnel-0.1.0-py3-none-any.whl) (with [a PGP signature](https://devel.ringlet.net/files/net/test-tunnel/test_tunnel-0.1.0-py3-none-any.whl.asc)) [0.1.1]: https://gitlab.com/ppentchev/test-tunnel/-/tags/release%2F0.1.1 [0.1.0]: https://gitlab.com/ppentchev/test-tunnel/-/tags/release%2F0.1.0 test_tunnel-0.1.1/docs/index.md0000644000000000000000000000722013615410400013357 0ustar00 # The test-tunnel library: write tests for network tunnelling utilities \[[Home][ringlet-home] | [GitLab][gitlab] | [Download](download.md) | [PyPI][pypi] | [ReadTheDocs][readthedocs]\] ## Overview The `test-tunnel` library's purpose is to make it easy to write either command-line tools or test modules that start some network tunnelling server (e.g. stunnel, microsocks, Dante) and verify that it does indeed forward connections and data as expected. ## A tunnel test scenario Test classes derived from the `test-tunnel` library's [TestTunnel][test_tunnel.run_test.TestTunnel] class have a [run()][test_tunnel.run_test.TestTunnel.run] method that performs the following actions: - examines the IPv4 and IPv6 network interfaces currently configured on the running system and picks two available ports to listen on for each one - makes a "possible connections" mapping, determining which of these addresses may be used as source and destination addresses for TCP connections. It is possible that some pairs are invalid either due to network protocol limitations or due to local system policy. - picks a set of (server, proxy as client, proxy as server, client) address/port combinations from the above mapping so that the client may connect to the proxy and the proxy, in turn, may connect to the server ## Writing a test class for a new tool To write a new test class it is enough to create a new Python class derived from [test_tunnel.run_test.TestTunnel][] and implement at least the three methods defined as abstract in that base class: - [slug()][test_tunnel.run_test.TestTunnel.slug]: return a short text string used in log messages to identify the tested program (e.g. "microsocks", "socat") - [do_spawn_server()][test_tunnel.run_test.TestTunnel.do_spawn_server]: start the tested tool with the specified address and port to listen on and address and port to forward connections to. This method may possibly prepare a configuration file if the tool needs it, or it may start the tool and pass the addresses and ports directly on the command line if supported. - [do_handshake()][test_tunnel.run_test.TestTunnel.do_handshake]: once a client socket has been connected to the already started tool (see the [do_spawn_server()][test_tunnel.run_test.TestTunnel.do_spawn_server] method), send and receive any "handshake" data required to make the tool establish a connection to the test listener started by the `test-tunnel` library itself. For a SOCKS5 server this should be the protocol negotiation and authentication, for an HTTP proxy server this would be the `CONNECT` request, etc. ## Example tools The `test-tunnel` library contains two example command-line tools that implement the test classes for two data forwarding programs: the [socat][test_tunnel.cmd_test.socat] multipurpose relay tool and the [microsocks][test_tunnel.cmd_test.microsocks] SOCKS5 server. They may serve as a starting point for writing new test classes. ## Contact The `test-tunnel` library was written by [Peter Pentchev][roam]. It is developed in [a GitLab repository][gitlab]. This documentation is hosted at [Ringlet][ringlet-home] with a copy at [ReadTheDocs][readthedocs]. [roam]: mailto:roam@ringlet.net "Peter Pentchev" [gitlab]: https://gitlab.com/ppentchev/test-tunnel "The test-tunnel GitLab repository" [pypi]: https://pypi.org/project/test-tunnel/ "The test-tunnel Python Package Index page" [readthedocs]: https://test-tunnel.readthedocs.io/ "The test-tunnel ReadTheDocs page" [ringlet-home]: https://devel.ringlet.net/net/test-tunnel/ "The Ringlet test-tunnel homepage" test_tunnel-0.1.1/docs/api/addresses.md0000644000000000000000000000130713615410400014776 0ustar00 # The test-tunnel network address discovery module ::: test_tunnel.addresses options: members: [] ## Data structures ::: test_tunnel.addresses.Address ::: test_tunnel.addresses.AddrPort ## Exceptions ::: test_tunnel.addresses.UnsupportedAddressFamilyError ## Address and port discovery routines ::: test_tunnel.addresses.get_addresses ::: test_tunnel.addresses.find_ports ## Address selection and combining routines ::: test_tunnel.addresses.find_pairs ::: test_tunnel.addresses.pick_pairs ## Utility functions ::: test_tunnel.addresses.family_id ::: test_tunnel.addresses.bind_to test_tunnel-0.1.1/docs/api/cmd_test.md0000644000000000000000000000060013615410400014616 0ustar00 # The test-tunnel sample test tools ::: test_tunnel.cmd_test options: members: [] ## Test the `microsocks` simple SOCKS5 server implementation ::: test_tunnel.cmd_test.microsocks ## Test the `socat` tool's TCP connection forwarding ::: test_tunnel.cmd_test.socat test_tunnel-0.1.1/docs/api/defs.md0000644000000000000000000000044313615410400013742 0ustar00 # The test-tunnel common definitions module ::: test_tunnel.defs options: members: [] ## Configuration classes ::: test_tunnel.defs.Config ::: test_tunnel.defs.ConfigProg test_tunnel-0.1.1/docs/api/index.md0000644000000000000000000000071013615410400014125 0ustar00 # API Reference ## The test-tunnel library - [`test_tunnel.defs`: the common definitions module](defs.md) - [`test_tunnel.addresses`: the network interface address discovery module](addresses.md) - [`test_tunnel.run_test`: the test runner infrastructure](run_test.md) - [`test_tunnel.cmd_test`: the example command-line testing tools](cmd_test.md) test_tunnel-0.1.1/docs/api/run_test.md0000644000000000000000000000041713615410400014665 0ustar00 # The test-tunnel runner infrastructure ::: test_tunnel.run_test options: members: [] ## The test runner base class ::: test_tunnel.run_test.TestTunnel test_tunnel-0.1.1/requirements/docs.txt0000644000000000000000000000032413615410400015210 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause mkdocs >= 1.4.2, < 2 mkdocs-material >= 9.1.2, < 10 mkdocstrings >= 0.22, < 0.26 mkdocstrings-python >= 1, < 2 test_tunnel-0.1.1/requirements/install.txt0000644000000000000000000000024513615410400015730 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause click >= 7, < 9 netifaces >= 0.10, < 0.12 utf8_locale >= 1, < 2 test_tunnel-0.1.1/requirements/ruff.txt0000644000000000000000000000016313615410400015223 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause ruff == 0.5.6 test_tunnel-0.1.1/src/test_tunnel/__init__.py0000644000000000000000000000044613615410400016245 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Top-level definitions for the tunnel testing library.""" from __future__ import annotations import typing if typing.TYPE_CHECKING: from typing import Final VERSION: Final = "0.1.1" test_tunnel-0.1.1/src/test_tunnel/addresses.py0000644000000000000000000003333113615410400016462 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Find addresses and ports for the tunnel testing library. This module contains helper functions for obtaining the IPv4 and IPv6 addresses of the system's network interfaces, determining which port numbers are available for listening on each of those addresses, and then determining which pairs of addresses may be used as source and destination for TCP connections. Its purpose is to enable a testing tool to listen on one address and establish a client connection from another one, with the listener and the client possibly in separate processes (or even programs). """ from __future__ import annotations import dataclasses import ipaddress import itertools import random import socket import sys import typing import netifaces if typing.TYPE_CHECKING: from collections.abc import Iterable from typing import Final from . import defs @dataclasses.dataclass class UnsupportedAddressFamilyError(Exception): """An unsupported address family was specified.""" family: int def __str__(self) -> str: """Provide a human-readable description of the error.""" return f"Unsupported address family {self.family}" @dataclasses.dataclass(frozen=True, order=True) class Address: """Information about a single network address on the system.""" family: int address: str packed: bytes @dataclasses.dataclass(frozen=True) class AddrPort: """An address and two "free" ports to listen on during the test run.""" address: Address svc_port: int proxy_port: int clients: list[Address] IPClassType = type[ipaddress.IPv4Address] | type[ipaddress.IPv6Address] @dataclasses.dataclass(frozen=True) class _IPFamily: """An IP address family and the corresponding ipaddress class.""" family: int short_id: str ipcls: IPClassType max_prefix_length: int _FAMILIES: list[_IPFamily] = [ _IPFamily(socket.AF_INET, "4", ipaddress.IPv4Address, 32), _IPFamily(socket.AF_INET6, "6", ipaddress.IPv6Address, 128), ] def get_addresses(cfg: defs.Config) -> list[Address]: """Get the IPv4 and IPv6 addresses on this system.""" cfg.log.debug("Enumerating the system network interfaces") ifaces: Final = netifaces.interfaces() cfg.log.debug("- got %(count)d interface names", {"count": len(ifaces)}) if not ifaces: return [] def add_addresses( family: int, ipcls: IPClassType, addrs: Iterable[str], ) -> list[Address]: """Create objects for the IPv4/IPv6 addresses found on an interface.""" res: Final = [] for addr in addrs: try: ipaddr = ipcls(addr) except ValueError as err: cfg.log.debug( "- could not parse the %(addr)r address: %(err)s", {"addr": addr, "err": err}, ) continue res.append(Address(family=family, address=addr, packed=ipaddr.packed)) cfg.log.debug("- added %(addr)r", {"addr": res[-1]}) return res return sorted( itertools.chain( *( add_addresses( ipfamily.family, ipfamily.ipcls, (addr["addr"] for addr in addrs.get(ipfamily.family, [])), ) for addrs, ipfamily in itertools.product( (netifaces.ifaddresses(iface) for iface in netifaces.interfaces()), _FAMILIES, ) ), ), ) def bind_to(cfg: defs.Config, addr: Address, port: int) -> socket.socket: """Bind to the specified port on the specified address.""" try: sock: Final = socket.socket(addr.family, socket.SOCK_STREAM, socket.IPPROTO_TCP) except OSError as err: cfg.log.debug( "Could not create a family %(family)d socket: %(err)s", {"family": addr.family, "err": err}, ) raise try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) except OSError as err: cfg.log.debug("Could not set the reuse-port option: %(err)s", {"err": err}) sock.close() raise try: sock.bind((addr.address, port)) except OSError as err: cfg.log.debug( "Could not bind to port %(port)d on %(addr)s: %(err)s", {"port": port, "addr": addr.address, "err": err}, ) sock.close() raise return sock def _find_available_port(cfg: defs.Config, addr: Address, port: int) -> int | None: """Find a port to listen on at the specified address.""" try: sock = bind_to(cfg, addr, 0) except OSError: return None cfg.log.debug(" - bound to a random port: %(sockname)r", {"sockname": sock.getsockname()}) sock.close() for _ in range(100): port += random.randint(10, 30) # noqa: S311 cfg.log.debug("- trying %(port)d", {"port": port}) try: sock = bind_to(cfg, addr, port) except OSError: continue cfg.log.debug(" - success!") sock.close() return port cfg.log.debug("- could not find an available port at all...") return None def find_ports(cfg: defs.Config, addrs: list[Address], first_port: int = 6374) -> list[AddrPort]: """Find two ports per network address to listen on. The search starts from `first_port + cfg.offset` as a port number. Port numbers are never reused, even for different addresses. """ res: Final[list[AddrPort]] = [] first_port += cfg.offset for addr in addrs: cfg.log.debug( "Looking for a service port to listen on for %(addr)s family %(family)d", {"addr": addr.address, "family": addr.family}, ) svc_port = _find_available_port(cfg, addr, first_port) if svc_port is None: cfg.log.debug("Could not find a service port on %(addr)s", {"addr": addr.address}) continue cfg.log.debug("Looking for a proxy port to listen on for %(addr)s", {"addr": addr.address}) proxy_port = _find_available_port(cfg, addr, svc_port) if proxy_port is None: cfg.log.debug("Could not find a service port on %(addr)s", {"addr": addr.address}) continue res.append( AddrPort( address=addr, svc_port=svc_port, proxy_port=proxy_port, clients=[], ), ) cfg.log.debug("Added %(addr)r", {"addr": res[-1]}) # Make sure we never pick the same port, even on different addresses, just in case first_port = proxy_port return res def _check_connect(cfg: defs.Config, server: socket.socket, client: Address) -> bool: """Check whether a client socket can connect to the server one.""" cfg.log.debug( "- checking whether %(client)s can connect to %(server)r", {"client": client.address, "server": server}, ) with bind_to(cfg, client, 0) as sock: cfg.log.debug(" - got client socket %(sock)r", {"sock": sock}) try: sock.connect(server.getsockname()) except OSError as err: cfg.log.debug(" - failed to connect: %(err)s", {"err": err}) return False try: csock, cdata = server.accept() except OSError as err: cfg.log.debug(" - failed to accept the connection: %(err)s", {"err": err}) return False cfg.log.debug(" - got socket %(csock)r data %(cdata)r", {"csock": csock, "cdata": cdata}) try: if ( csock.getsockname() != sock.getpeername() or csock.getpeername() != sock.getsockname() ): cfg.log.debug( " - get*name() mismatch between %(csock)r and %(sock)r", {"csock": csock, "sock": sock}, ) return False cfg.log.debug(" - success!") return True finally: csock.close() def find_pairs(cfg: defs.Config, ports: list[AddrPort]) -> dict[int, list[AddrPort]]: """Figure out which addresses can connect to which other addresses.""" def find_single(port: AddrPort, others: Iterable[AddrPort]) -> AddrPort: """Find which clients can connect to the specified server port.""" cfg.log.debug("Checking whether we can connect to %(addr)s", {"addr": port.address.address}) with bind_to(cfg, port.address, port.svc_port) as svc_sock: svc_sock.listen(10) with bind_to(cfg, port.address, port.proxy_port) as proxy_sock: proxy_sock.listen(10) return dataclasses.replace( port, clients=[ other.address for other in others if _check_connect(cfg, svc_sock, other.address) and _check_connect(cfg, proxy_sock, other.address) ], ) return { family: data for family, data in ( ( family, [ res_port for res_port in ( find_single(port, (other for other in lports if other != port)) for port in lports ) if res_port.clients ], ) for family, lports in ( (family, list(fports)) for family, fports in itertools.groupby(ports, lambda port: port.address.family) ) ) if data } def pick_pairs( cfg: defs.Config, apairs: dict[int, list[AddrPort]], ) -> list[tuple[AddrPort, AddrPort]]: """Pick two (maybe the same) addresses for each family.""" def reorder(server: Address, clients: list[Address]) -> list[Address]: """Sort the addresses, put the server's own address at the end.""" return [addr for addr in sorted(clients) if addr != server] + [ addr for addr in clients if addr == server ] res: Final[dict[int, tuple[AddrPort, AddrPort]]] = {} for family, pairs in apairs.items(): if len(pairs) == 1: cfg.log.debug("Considering a single set for %(family)r", {"family": family}) first = pairs[0] clients = reorder(first.address, first.clients) r_first = dataclasses.replace(first, clients=[clients[0]]) r_second = dataclasses.replace( first, clients=[clients[0] if len(clients) == 1 else clients[1]], ) else: cfg.log.debug("Considering two sets for %(family)r", {"family": family}) first, second = pairs[0], pairs[1] c_first, c_second = ( reorder(first.address, first.clients), reorder(second.address, second.clients), ) r_first, r_second = ( dataclasses.replace(first, clients=[c_first[0]]), dataclasses.replace(second, clients=[c_second[0]]), ) if len(r_first.clients) != 1 or len(r_second.clients) != 1: sys.exit( f"Internal error: unexpected number of clients: " f"{r_first.clients=!r} {r_second.clients=!r}", ) if (r_first.address, {r_first.svc_port, r_first.proxy_port}) == ( r_second.address, {r_second.svc_port, r_second.proxy_port}, ): # So basically we only have a single address to work with... cfg.log.debug( "Looking for more ports to listen on at %(second)s", {"second": r_second.address}, ) more_ports = find_ports( cfg, [r_second.address], first_port=max(r_second.svc_port, r_second.proxy_port) + 1, )[0] r_second = dataclasses.replace( r_second, svc_port=more_ports.svc_port, proxy_port=more_ports.proxy_port, ) if {r_first.svc_port, r_first.proxy_port} == { r_second.svc_port, r_second.proxy_port, }: sys.exit( f"Internal error: duplicate port pairs: " f"{r_first.svc_port!r} {r_first.proxy_port!r} " f"{r_second.svc_port!r} {r_second.proxy_port!r}", ) res[family] = (r_first, r_second) cfg.log.debug( "Address family %(family)r: picked %(addr)r", {"family": family, "addr": res[family]}, ) return [ (res[first][0], res[second][1]) for first, second in itertools.product(res.keys(), res.keys()) ] def family_id(family: int) -> str: """Return a '4' or '6' specification depending on the address family.""" try: return next(ipfamily.short_id for ipfamily in _FAMILIES if ipfamily.family == family) except StopIteration as err: raise UnsupportedAddressFamilyError(family=family) from err def prefix_length(family: int) -> int: """Return the maximum prefix length - 32 or 128 - depending on the address family.""" try: return next( ipfamily.max_prefix_length for ipfamily in _FAMILIES if ipfamily.family == family ) except StopIteration as err: raise UnsupportedAddressFamilyError(family=family) from err def ipclass(family: int) -> IPClassType: """Return the IP address class appropriate for the address family.""" try: return next(ipfamily.ipcls for ipfamily in _FAMILIES if ipfamily.family == family) except StopIteration as err: raise UnsupportedAddressFamilyError(family=family) from err test_tunnel-0.1.1/src/test_tunnel/defs.py0000644000000000000000000000263013615410400015424 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Common definitions for the tunnel testing library.""" from __future__ import annotations import dataclasses import typing if typing.TYPE_CHECKING: import logging import pathlib @dataclasses.dataclass(frozen=True) class Config: """Common configuration for the tunnel testing library. This is the base class for the various configuration settings passed to the `test-tunnel` library's routines. For the present it is used by the functions in the `test_tunnel.addresses` module. """ log: logging.Logger """The logger to send diagnostic and informational messages to.""" offset: int """The number of ports to skip when looking for available ones.""" verbose: bool """Has verbose logging been enabled?""" @dataclasses.dataclass(frozen=True) class ConfigProg(Config): """Common configuration also including a program to test in a UTF-8-capable environment. This class extends the base `Config` class, adding the path to the program that will be tested (the tunnel proxy/server tool) and a set of variable/value pairs to pass to child processes to ensure their output may be parsed as UTF-8 strings. """ prog: pathlib.Path """The path to the program to test.""" utf8_env: dict[str, str] """The UTF-8-capable environment settings.""" test_tunnel-0.1.1/src/test_tunnel/py.typed0000644000000000000000000000000013615410400015615 0ustar00test_tunnel-0.1.1/src/test_tunnel/run_test.py0000644000000000000000000003034313615410400016350 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Provide a base class for running tunnel tests. This module provides the `TestTunnel` class to be used as a base class for implementing test runners for various tunnel programs. Its `run()` method sets the test environment up, determines addresses and ports to use for testing the connections, invokes an implementation-specific method to start the tunnel/proxy server, and then loops over a predetermined set of connection addresses, again invoking an implementation-specific method to prepare an established TCP connection for forwarding data. The `test_tunnel.cmd_test` sample modules may be used as a starting point for implementing tool-specific test classes using `TestTunnel`. """ from __future__ import annotations import abc import contextlib import errno import sys import time import typing from . import addresses from . import defs if typing.TYPE_CHECKING: import socket import subprocess # noqa: S404 from collections.abc import Iterator from typing import Final class TestTunnel(abc.ABC): """Base class for running tunnel tests. This class mainly provides the `run()` method that determines some addresses and ports to use for the connections, sets up a listener, and invokes implementation-specific methods to start the tunnel/proxy server and prepare some TCP connections for forwarding data. A tool-specific implementation must override at least the `slug()`, `do_spawn_server()`, and `do_handshake()` methods. """ cfg: defs.ConfigProg """The tunnel proxy configuration.""" def __init__(self, cfg: defs.ConfigProg) -> None: """Store the configuration object.""" self.cfg = cfg self.cfg.log.debug( "Using %(locale)s as a UTF-8 locale.", {"locale": cfg.utf8_env["LC_ALL"]}, ) @abc.abstractmethod def slug(self) -> str: """Provide a short string to identify the tested tunnel implementation. The returned string is used in logging and diagnostic messages so that it is clear which tool is being tested. """ raise NotImplementedError def do_test_conn_connect( self, cli_sock: socket.socket, address: addresses.Address, port: int, ) -> None: """Connect to the specified address/port. This method is invoked internally by `run()` to make sure that the tunnel/proxy server started by the `do_spawn_server()` method can at least accept TCP connections at the addresses it should have been configured to listen on. """ dest: Final = (address.address, port) self.cfg.log.info( "Connecting to the %(slug)s server at %(dest)r", {"slug": self.slug(), "dest": dest}, ) for _ in range(10): try: cli_sock.connect(dest) break except OSError as err: if err.errno != errno.ECONNREFUSED: raise self.cfg.log.debug("Could not connect, waiting for a second") time.sleep(1) else: sys.exit(f"Could not connect to the {self.slug()} server at {dest} after ten attempts") def expect_read( self, sock: socket.socket, expected: bytes, tag: str, *, compare_len: int | None = None, ) -> bytes: """Read some data, make sure it is as expected. This method is used internally by `run()` after establishing a TCP connection through the tunnel/proxy server (and after the `do_handshake()` method has set the connection up) to make sure that the data sent along the connection is forwarded correctly. If the `compare_len` parameter is specified, only so many bytes at the start of the response are compared; the rest is allowed to vary, although it must still have the expected total length in bytes. """ if compare_len is None: compare_len = len(expected) data: Final = sock.recv(4096) self.cfg.log.debug("- got %(data)r", {"data": data}) if data[:compare_len] != expected[:compare_len]: sys.exit(f"Unexpected {tag} (comparing {compare_len} bytes): {expected=!r} {data=!r}") return data @abc.abstractmethod def do_handshake( self, cli_sock: socket.socket, svc_listen: addresses.AddrPort, ) -> tuple[addresses.Address, int] | None: """Perform the protocol-specific tunnel handshake. Once the tunnel/proxy server has been started by `do_spawn_server()` and a client connection to it has been established by `run()`, this method sends and receives any data necessary for a "handshake" as required by the tunnel protocol, e.g. negotiation and authentication for a SOCKS5 proxy, a CONNECT request for an HTTP/HTTPS proxy, etc. This method may optionally return the source address and port of the forwarded connection if it can be obtained from the tunnel protocol, e.g. SOCKS5 will sometimes return that information. """ raise NotImplementedError def do_test_conn_xfer( self, cli_sock: socket.socket, srv_sock: socket.socket, svc_listen: addresses.AddrPort, ) -> None: """Perform the protocol handshake and conversation. Invoked internally by `run()`, this method checks that a client connection to the internal server can indeed be established via the tunnel/proxy server (using the `do_handshake()` method), and that it can indeed forward data in both directions. """ self.do_handshake(cli_sock, svc_listen) self.cfg.log.debug("Accepting a connection from the %(slug)s server", {"slug": self.slug()}) (conn_sock, conn_data) = srv_sock.accept() self.cfg.log.debug( "- accepted a connection on fd %(fileno)d from %(conn_data)r", {"fileno": conn_sock.fileno(), "conn_data": conn_data}, ) if conn_sock.family != svc_listen.address.family: sys.exit( f"Expected a {svc_listen.address.family} family connection, got {conn_sock.family}", ) self.cfg.log.debug("Let's say hello to the client") expected: Final = b"Hello" conn_sock.send(expected) self.cfg.log.debug("Let's try to read that from the client side") self.expect_read(cli_sock, expected, "client read") self.cfg.log.debug("Closing the client connection") cli_sock.close() self.cfg.log.debug("Let's get an empty read from the server side") self.expect_read(conn_sock, b"", "server side empty read") self.cfg.log.debug("Closing the server side of the connection") conn_sock.close() @abc.abstractmethod @contextlib.contextmanager def do_spawn_server( self, proxy_listen: addresses.AddrPort, svc_listen: addresses.AddrPort, ) -> Iterator[subprocess.Popen[str]]: """Spawn the tunnel-specific server process. This method will most probably invoke an external program to start the implementation-specific tunnel/proxy server. It may also possibly create a configuration file in a temporary directory before running the program itself. It must return a process instance that the `run()` method may monitor and wait for after completing the connection and data transfer tests. """ raise NotImplementedError def test_conn( self, proxy_listen: addresses.AddrPort, svc_listen: addresses.AddrPort, ) -> None: """Test the connectivity across a tunnel.""" self.cfg.log.info( "Client at %(proxy_addr)s port %(proxy_port)d", {"proxy_addr": proxy_listen.clients[0], "proxy_port": proxy_listen.proxy_port}, ) self.cfg.log.info( "Proxy at %(listen_addr)s port %(listen_port)d", {"listen_addr": proxy_listen.address, "listen_port": proxy_listen.svc_port}, ) self.cfg.log.info( "Proxy client at %(client_addr)s port %(client_port)d", {"client_addr": svc_listen.clients[0], "client_port": svc_listen.proxy_port}, ) self.cfg.log.info( "Server at %(server_addr)s port %(server_port)d", {"server_addr": svc_listen.address, "server_port": svc_listen.svc_port}, ) self.cfg.log.info("Creating the server listening socket") with addresses.bind_to(self.cfg, svc_listen.address, svc_listen.svc_port) as srv_sock: self.cfg.log.debug("Server socket: %(srv_sock)r", {"srv_sock": srv_sock}) srv_sock.listen(1) self.cfg.log.info("Spawning the %(slug)s server process", {"slug": self.slug()}) with self.do_spawn_server(proxy_listen, svc_listen) as sproc: self.cfg.log.debug( "- spawned %(slug)s server at %(pid)d", {"slug": self.slug(), "pid": sproc.pid}, ) try: self.cfg.log.info("Creating the client socket") with addresses.bind_to( self.cfg, proxy_listen.clients[0], proxy_listen.proxy_port, ) as cli_sock: self.cfg.log.debug("Client socket: %(cli_sock)r", {"cli_sock": cli_sock}) self.do_test_conn_connect( cli_sock, proxy_listen.address, proxy_listen.svc_port, ) self.cfg.log.debug("- connected: %(cli_sock)r", {"cli_sock": cli_sock}) self.do_test_conn_xfer(cli_sock, srv_sock, svc_listen) self.cfg.log.debug("Stopping the %(slug)s server", {"slug": self.slug()}) sproc.terminate() self.cfg.log.debug( "Waiting for the %(slug)s server process to exit", {"slug": self.slug()}, ) self.cfg.log.info("%(output)r", {"output": sproc.communicate()}) self.cfg.log.debug( "%(slug)s server exit code %(res)d", {"slug": self.slug(), "res": sproc.wait()}, ) except BaseException as err: self.cfg.log.debug( "Killing the %(slug)s server because of an exception: %(err)s", {"slug": self.slug(), "err": err}, ) sproc.kill() raise def run(self) -> None: """Gather addresses, group them in pairs, run tests. This is the main entry point for instances of classes derived from `TestTunnel`. It uses the `test_tunnel.addresses` module's routines to determine several sets of addresses to use for a client connection, a proxy server, and an internal server, and then uses the implementation-specific methods to start a tunnel/proxy server, establish a client connection through it, and make sure that data can flow both ways. """ self.cfg.log.debug("Starting up") apairs: Final = addresses.find_pairs( self.cfg, addresses.find_ports(self.cfg, addresses.get_addresses(self.cfg)), ) self.cfg.log.info("Connectivity information: address family, server, clients:") for family, ports in sorted(apairs.items()): self.cfg.log.info("%(family)d", {"family": family}) for port in ports: self.cfg.log.info("\t%(address)s", {"address": port.address.address}) for client in port.clients: self.cfg.log.info("\t\t%(client)s", {"client": client.address}) self.cfg.log.info("Picking two pairs for each address family, if possible") selected = addresses.pick_pairs(self.cfg, apairs) self.cfg.log.info("Testing %(count)d combination(s)", {"count": len(selected)}) for proxy_listen, svc_listen in selected: self.test_conn(proxy_listen, svc_listen) test_tunnel-0.1.1/src/test_tunnel/cmd_test/__init__.py0000644000000000000000000000026413615410400020045 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """A collection of sample command-line tools to test tunnel implementations.""" test_tunnel-0.1.1/src/test_tunnel/cmd_test/microsocks.py0000644000000000000000000001156513615410400020470 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Run some tests on the microsocks proxy server and client.""" from __future__ import annotations import contextlib import dataclasses import socket import struct import subprocess # noqa: S404 import typing from test_tunnel import addresses from test_tunnel import run_test from test_tunnel.cmd_test import util as cmd_util if typing.TYPE_CHECKING: from collections.abc import Iterator from typing import Final from test_tunnel import defs ATYP_INET: Final = 1 """The SOCKS5 address type for IPv4.""" ATYP_INET6: Final = 4 """The SOCKS5 address type for IPv6.""" @dataclasses.dataclass class UnsupportedAddressTypeError(Exception): """An unsupported address family was specified.""" atyp: int def __str__(self) -> str: """Provide a human-readable description of the error.""" return f"Unsupported SOCKS5 address type {self.atyp}" class TestMicroSOCKS(run_test.TestTunnel): """Run the tunnel tests using a microsocks server.""" def slug(self) -> str: """Identify the microsocks tunnel.""" return "microsocks" @classmethod def quirk_server_returns_null_ipv4_response(cls) -> bool: """Expect 4 + 2 zeroes as a response to the "connect" request. The microsocks server does not bother returning any connection information in the response to the "connect" request; instead, it returns an address type of IPv4 and four + two bytes of zeroes. """ return True def do_handshake( self, cli_sock: socket.socket, svc_listen: addresses.AddrPort, ) -> tuple[addresses.Address, int] | None: """Perform the SOCKS5 handshake.""" def get_atyp(family: int) -> int: """Get the SOCKS5 "address type" value depending on the address family.""" if family == socket.AF_INET: return ATYP_INET if family == socket.AF_INET6: return ATYP_INET6 raise addresses.UnsupportedAddressFamilyError(family) def get_family(atyp: int) -> int: """Get the IP address family depending on the SOCKS5 "address type" value.""" if atyp == ATYP_INET: return socket.AF_INET if atyp == ATYP_INET6: return socket.AF_INET6 raise UnsupportedAddressTypeError(atyp) self.cfg.log.debug("Sending 'none' auth") cli_sock.send(bytes([5, 1, 0])) self.cfg.log.debug("Waiting for the server's auth response") self.expect_read(cli_sock, bytes([5, 0]), "auth response") family: Final = get_atyp(svc_listen.address.family) data: Final = ( bytes([5, 1, 0, family]) + svc_listen.address.packed + struct.pack(">h", svc_listen.svc_port) ) self.cfg.log.debug("Sending a SOCKS5 CONNECT request: %(data)r", {"data": data}) cli_sock.send(data) self.cfg.log.debug("Waiting for the server's connect response") ack = bytes([5, 0, 0]) if self.quirk_server_returns_null_ipv4_response(): ack += bytes([1] + [0] * 4 + [0] * 2) self.expect_read(cli_sock, ack, "connect response") return None ack += bytes([family]) compare_len: Final = len(ack) ack += bytes([0] * len(svc_listen.address.packed) + [0] * 2) resp: Final = self.expect_read(cli_sock, ack, "connect response", compare_len=compare_len) res_family, buf_addr, buf_port = ( get_family(resp[compare_len - 1]), resp[compare_len:-2], resp[-2:], ) res_addr: Final = addresses.ipclass(res_family)(buf_addr) res_port: Final = struct.unpack(">h", buf_port)[0] return ( addresses.Address(family=res_family, address=str(res_addr), packed=buf_addr), res_port, ) @contextlib.contextmanager def do_spawn_server( self, proxy_listen: addresses.AddrPort, svc_listen: addresses.AddrPort, ) -> Iterator[subprocess.Popen[str]]: """Spawn the microsocks proxy process.""" yield subprocess.Popen( # noqa: S603 [ self.cfg.prog, "-i", proxy_listen.address.address, "-p", str(proxy_listen.svc_port), "-b", svc_listen.clients[0].address, ], bufsize=0, encoding="UTF-8", env=self.cfg.utf8_env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @cmd_util.click_common_args("microsocks") def main(cfg: defs.ConfigProg) -> None: """Parse command-line arguments, prepare the environment, run tests.""" tester: Final = TestMicroSOCKS(cfg) tester.run() if __name__ == "__main__": main() test_tunnel-0.1.1/src/test_tunnel/cmd_test/socat.py0000644000000000000000000000457613615410400017431 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Run some tests using the socat tool in forwarding mode.""" from __future__ import annotations import contextlib import subprocess # noqa: S404 import typing from test_tunnel import addresses from test_tunnel import run_test from test_tunnel.cmd_test import util as cmd_util if typing.TYPE_CHECKING: import socket from collections.abc import Iterator from typing import Final from test_tunnel import defs class TestSoCat(run_test.TestTunnel): """Run the tunnel tests using a socat instance.""" def slug(self) -> str: """Identify the socat tool.""" return "socat" def do_handshake( self, cli_sock: socket.socket, svc_listen: addresses.AddrPort, ) -> tuple[addresses.Address, int] | None: """No handshake for socat.""" self.cfg.log.info("No handshake necessary for socat") self.cfg.log.debug( "Nothing to do for a %(cli)s / %(svc)s connection", {"cli": cli_sock, "svc": svc_listen}, ) return None @contextlib.contextmanager def do_spawn_server( self, proxy_listen: addresses.AddrPort, svc_listen: addresses.AddrPort, ) -> Iterator[subprocess.Popen[str]]: """Spawn the socat proxy process.""" yield subprocess.Popen( # noqa: S603 [ self.cfg.prog, "-v", ( f"TCP{addresses.family_id(proxy_listen.address.family)}-LISTEN:" f"{proxy_listen.svc_port}," f"bind=[{proxy_listen.address.address}],reuseaddr,fork" ), ( f"TCP{addresses.family_id(svc_listen.clients[0].family)}:" f"[{svc_listen.address.address}]:{svc_listen.svc_port}," f"bind=[{svc_listen.clients[0].address}]" ), ], bufsize=0, encoding="UTF-8", env=self.cfg.utf8_env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @cmd_util.click_common_args("socat") def main(cfg: defs.ConfigProg) -> None: """Parse command-line arguments, prepare the environment, run tests.""" tester: Final = TestSoCat(cfg) tester.run() if __name__ == "__main__": main() test_tunnel-0.1.1/src/test_tunnel/cmd_test/util.py0000644000000000000000000000502513615410400017263 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Common utilities for the command-line test tools.""" from __future__ import annotations import logging import pathlib import sys import typing import click import utf8_locale from test_tunnel import defs if typing.TYPE_CHECKING: from collections.abc import Callable from typing import Final def build_logger(*, verbose: bool) -> logging.Logger: """Build the logger to send diagnostic and informational messages to.""" logger: Final = logging.getLogger() logger.setLevel(logging.DEBUG if verbose else logging.INFO) diag_handler: Final = logging.StreamHandler(sys.stderr) diag_handler.setLevel(logging.DEBUG) diag_handler.addFilter(lambda rec: rec.levelno == logging.DEBUG) logger.addHandler(diag_handler) info_handler: Final = logging.StreamHandler(sys.stdout) info_handler.setLevel(logging.INFO) info_handler.addFilter(lambda rec: rec.levelno == logging.INFO) logger.addHandler(info_handler) err_handler: Final = logging.StreamHandler(sys.stderr) err_handler.setLevel(logging.WARNING) logger.addHandler(err_handler) return logger def click_common_args( prog: str, ) -> Callable[[Callable[[defs.ConfigProg], None]], Callable[[], None]]: """Wrap a main function, process the common options.""" def wrap_wrapper(func: Callable[[defs.ConfigProg], None]) -> Callable[[], None]: """Wrap the main function, process the common options.""" @click.command(name=prog, help=f"Run the {prog} test.") @click.option( "-O", "--offset", type=int, default=0, help="the number of ports to skip when looking for available ones", ) @click.option( "-p", "--prog", type=pathlib.Path, required=True, help="the path to the program to test", ) @click.option( "-v", "--verbose", is_flag=True, help="verbose mode; display diagnostic output", ) def wrapper(*, offset: int, prog: pathlib.Path, verbose: bool) -> None: """Run the tunnel test.""" cfg = defs.ConfigProg( log=build_logger(verbose=verbose), offset=offset, verbose=verbose, prog=prog, utf8_env=utf8_locale.get_utf8_env(), ) func(cfg) return wrapper return wrap_wrapper test_tunnel-0.1.1/stubs/netifaces.pyi0000644000000000000000000000062613615410400014625 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Type stubs for the netifaces library.""" from typing import TypedDict class AddressRecord(TypedDict): """A single address record returned for an interface.""" addr: str netmask: str def interfaces() -> list[str]: ... def ifaddresses(iface: str) -> dict[int, list[AddressRecord]]: ... test_tunnel-0.1.1/.gitignore0000644000000000000000000000016013615410400012762 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause .tox site test_tunnel-0.1.1/pyproject.toml0000644000000000000000000000414413615410400013714 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause [build-system] requires = [ "hatchling >= 1.8, < 2", "hatch-requirements-txt >= 0.3, < 0.5", ] build-backend = "hatchling.build" [project] name = "test-tunnel" description = "Framework for testing client/server network tunnelling libraries" requires-python = ">= 3.10" license = {"text" = "GPL-2+"} classifiers = [ "Development Status :: 4 - Beta", "Framework :: AsyncIO", "Intended Audience :: Developers", "License :: DFSG approved", "License :: Freely Distributable", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Internet :: Proxy Servers", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Testing", "Topic :: System :: Networking", "Typing :: Typed", ] dynamic = ["dependencies", "version"] [[project.authors]] name = "Peter Pentchev" email = "roam@ringlet.net" [tool.hatch.build.targets.wheel] packages = ["src/test_tunnel"] [tool.hatch.metadata.hooks.requirements_txt] files = ["requirements/install.txt"] [tool.hatch.version] path = "src/test_tunnel/__init__.py" pattern = '(?x) ^ VERSION \s* (?: : \s* Final \s* )? = \s* " (?P [^\s"]+ ) " \s* $' [tool.mypy] strict = true [tool.publync.format.version] major = 0 minor = 1 [tool.publync.build.tox] [tool.publync.sync.rsync] remote = "marla.ludost.net:vhosts/devel.ringlet.net/public_html/net/test-tunnel" [tool.ruff] extend = "ruff-base.toml" output-format = "concise" preview = true [tool.ruff.lint] select = ["ALL"] [tool.test-stages] stages = [ "@check and @quick and not @manual", "@check and not @manual or @docs and not @manual", "@tests and not @manual", ] test_tunnel-0.1.1/PKG-INFO0000644000000000000000000000234113615410400012072 0ustar00Metadata-Version: 2.3 Name: test-tunnel Version: 0.1.1 Summary: Framework for testing client/server network tunnelling libraries Author-email: Peter Pentchev License: GPL-2+ Classifier: Development Status :: 4 - Beta Classifier: Framework :: AsyncIO Classifier: Intended Audience :: Developers Classifier: License :: DFSG approved Classifier: License :: Freely Distributable Classifier: License :: OSI Approved :: BSD License Classifier: Operating System :: OS Independent Classifier: Operating System :: POSIX Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Topic :: Internet :: Proxy Servers Classifier: Topic :: Software Development :: Libraries Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Topic :: Software Development :: Testing Classifier: Topic :: System :: Networking Classifier: Typing :: Typed Requires-Python: >=3.10 Requires-Dist: click<9,>=7 Requires-Dist: netifaces<0.12,>=0.10 Requires-Dist: utf8-locale<2,>=1