diff options
26 files changed, 1195 insertions, 12 deletions
@@ -158,6 +158,7 @@ commands.h compile: \ warn-auto.sh conf-cc ( cat warn-auto.sh; \ + [ '$V' != '1' ] || echo echo "`head -1 conf-cc`" '-c $${1+"$$@"}'; \ echo exec "`head -1 conf-cc`" '-c $${1+"$$@"}' \ ) > compile chmod 755 compile @@ -384,6 +385,8 @@ load: \ warn-auto.sh conf-ld ( cat warn-auto.sh; \ echo 'main="$$1"; shift'; \ + [ '$V' != '1' ] || echo echo "`head -1 conf-ld`" \ + '-o "$$main" "$$main".o $${1+"$$@"}'; \ echo exec "`head -1 conf-ld`" \ '-o "$$main" "$$main".o $${1+"$$@"}' \ ) > load diff --git a/debian/.editorconfig b/debian/.editorconfig new file mode 100644 index 0000000..9969178 --- /dev/null +++ b/debian/.editorconfig @@ -0,0 +1,36 @@ +# https://editorconfig.org/ + +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +[*.1] +indent_style = space +indent_size = 4 + +[*.py] +indent_style = space +indent_size = 4 + +[*.pyi] +indent_style = space +indent_size = 4 + +[{changelog,control,copyright,watch}] +indent_style = space +indent_size = 4 + +[rules] +indent_style = tab +tab_size = 8 + +[setup.cfg] +indent_style = space +indent_size = 4 + +[tox.ini] +indent_style = space +indent_size = 2 diff --git a/debian/.gitlab-ci.yml b/debian/.gitlab-ci.yml new file mode 100644 index 0000000..cfb8c84 --- /dev/null +++ b/debian/.gitlab-ci.yml @@ -0,0 +1,5 @@ +include: + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/salsa-ci.yml + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/pipeline-jobs.yml +variables: + RELEASE: experimental diff --git a/debian/changelog b/debian/changelog index e1567a7..f0693d4 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,56 @@ +ucspi-tcp (1:0.88-8) unstable; urgency=medium + + * Declare dpkg-build-api v1, drop the implied Rules-Requires-Root: no + declaration. + * Use dh-package-notes to record ELF package metadata. + * autopkgtest: + - fix a server/client mismatch in a log message + - only pass "-R" if supported, do not pass "--" + - support verbose logs to the standard output stream + - run {proto}server with line-buffered stdout and stderr + - dynamic version, bump it to 0.2.0 + - read all the data from the socket + * autopkgtest internal test suite: + - tox.ini: add tags for running tox-stages + - do not pass the python_version option to mypy, let it check against + the Python interpreter that it is being run with + - use hatchling for the PEP517 build + - convert tox.ini to the Tox 4.x format + - use Ruff instead of black, flake8, and pylint + - reformat and refactor the source code according to Ruff's suggestions + + -- Peter Pentchev <roam@debian.org> Tue, 27 Feb 2024 11:57:42 +0200 + +ucspi-tcp (1:0.88-7) unstable; urgency=medium + + [ Dmitry Bogatov ] + * Add Gitlab CI config file + * Do not run tests under Gitlab CI + + [ Peter Pentchev ] + * New maintainer. Closes: #983804 + * Declare compliance with Policy 4.6.2 with no changes. + * Set the debhelper compat level to 13 with no changes. + * Add an EditorConfig definitions file. + * Run a custom test suite at build-time and as an autopkgtest. + * Clean up after a build. + * Show the compiler and linker invocations unless "terse" is requested. + * Do not use "tee" to generate the config files. + * Add Rules-Requires-Root: no to the source control stanza. + * Remove leading slashes from debian/*.install. + * No longer mention ucspi-tcp-doc, no such package for a long time. + + -- Peter Pentchev <roam@debian.org> Thu, 05 Jan 2023 04:05:08 +0200 + +ucspi-tcp (1:0.88-6) unstable; urgency=medium + + * Remove references to patched-out `rbl.maps.vix.com' host from manpage + (Closes: #681739) + * Install example http server, serving log file with chunked transfer + encoding (Closes: #772209) + + -- Dmitry Bogatov <KAction@debian.org> Thu, 17 Jan 2019 06:30:43 +0000 + ucspi-tcp (1:0.88-5) unstable; urgency=medium * Do not include build path into resulting scripts (Closes: #915511) diff --git a/debian/clean b/debian/clean new file mode 100644 index 0000000..af8fd4a --- /dev/null +++ b/debian/clean @@ -0,0 +1,4 @@ +conf-cc +conf-home +conf-ld +ipv6/ diff --git a/debian/contrib/httptaild b/debian/contrib/httptaild new file mode 100644 index 0000000..ffc7762 --- /dev/null +++ b/debian/contrib/httptaild @@ -0,0 +1,23 @@ +#!/bin/sh +# outputs a file over HTTP 1.1 and pushes updates when new lines are appended +# © 2014 Nils Dagsson Moskopp (erlehmann) – license: GPLv3+ +# httptaild is intended for usage with tcpserver(1) from ucspi-tcp: +# "tcpserver 0 8008 httptaild log" serves log on http://localhost:8008 + +status () { printf '%s\r\n' "HTTP/1.1 200 Ok"; } +header () { printf '%s\r\n' "$1"; } +divide () { printf '%s\r\n' "$1"; } +length () { printf '%x\r\n' "$1"; } +body_n () { printf '%s\n\r\n' "$1"; } + +status +header "Content-type: $(file --brief --mime "$1")" +header "Transfer-Encoding: chunked" +divide +length "$(wc -c <"$1")" +body_n "$(cat "$1")" + +tail -n0 -f "$1" | while IFS=$(printf '\n') read -r line; do + length "$(printf '%s\n' "$line" | wc -c)" + body_n "$line" +done diff --git a/debian/control b/debian/control index 663661b..4935615 100644 --- a/debian/control +++ b/debian/control @@ -1,10 +1,15 @@ Source: ucspi-tcp Section: net Priority: optional -Maintainer: Dmitry Bogatov <KAction@debian.org> +Maintainer: Peter Pentchev <roam@debian.org> Build-Depends: - debhelper-compat (= 11), -Standards-Version: 4.2.1 + debhelper-compat (= 13), + dh-package-notes, + dpkg-build-api (= 1), + python3 <!nocheck>, + python3-netifaces <!nocheck>, + python3-utf8-locale <!nocheck>, +Standards-Version: 4.6.2 Homepage: http://cr.yp.to/ucspi-tcp Vcs-Git: https://salsa.debian.org/debian/ucspi-tcp.git Vcs-Browser: https://salsa.debian.org/debian/ucspi-tcp @@ -12,7 +17,6 @@ Vcs-Browser: https://salsa.debian.org/debian/ucspi-tcp Package: ucspi-tcp Architecture: any Depends: ${shlibs:Depends}, ${misc:Depends} -Replaces: ucspi-tcp-doc Description: command-line tools for building TCP client-server applications tcpserver waits for incoming connections and, for each connection, runs a program of your choice. Your program receives environment variables showing @@ -43,7 +47,6 @@ Description: command-line tools for building TCP client-server applications Package: ucspi-tcp-ipv6 Architecture: any Depends: ${shlibs:Depends}, ${misc:Depends} -Replaces: ucspi-tcp-doc Conflicts: ucspi-tcp Provides: ucspi-tcp Description: command-line tools for building TCP client-server applications (IPv6) diff --git a/debian/patches/0005-build-verbose.patch b/debian/patches/0005-build-verbose.patch new file mode 100644 index 0000000..c30efb0 --- /dev/null +++ b/debian/patches/0005-build-verbose.patch @@ -0,0 +1,26 @@ +Description: Show the compiler and linker invocations during the build + If the "V" environment variable is set to the value "1", show + the compiler and linker commands before running them. +Forwarded: not-needed +Author: Peter Pentchev <roam@ringlet.net> +Last-Update: 2023-01-05 + +--- a/Makefile ++++ b/Makefile +@@ -158,6 +158,7 @@ + compile: \ + warn-auto.sh conf-cc + ( cat warn-auto.sh; \ ++ [ '$V' != '1' ] || echo echo "`head -1 conf-cc`" '-c $${1+"$$@"}'; \ + echo exec "`head -1 conf-cc`" '-c $${1+"$$@"}' \ + ) > compile + chmod 755 compile +@@ -384,6 +385,8 @@ + warn-auto.sh conf-ld + ( cat warn-auto.sh; \ + echo 'main="$$1"; shift'; \ ++ [ '$V' != '1' ] || echo echo "`head -1 conf-ld`" \ ++ '-o "$$main" "$$main".o $${1+"$$@"}'; \ + echo exec "`head -1 conf-ld`" \ + '-o "$$main" "$$main".o $${1+"$$@"}' \ + ) > load diff --git a/debian/patches/series b/debian/patches/series index e8da398..84c9cf3 100644 --- a/debian/patches/series +++ b/debian/patches/series @@ -2,3 +2,4 @@ 0002-rblsmtpd.c-don-t-use-a-the-default-rbl.maps.vix.com.diff 0003-Makefile-target-choose-do-not-depend-on-conf-home.diff 0004-respect-DESTDIR-variable.patch +0005-build-verbose.patch diff --git a/debian/rules b/debian/rules index d257207..32d5543 100755 --- a/debian/rules +++ b/debian/rules @@ -5,19 +5,32 @@ include /usr/share/dpkg/default.mk export CFLAGS += $(shell getconf LFS_CFLAGS) export LDFLAGS += $(shell getconf LFS_LDFLAGS) +include /usr/share/debhelper/dh_package_notes/package-notes.mk + PREFIX_IPV4 = $(CURDIR)/debian/ucspi-tcp PREFIX_IPV6 = $(CURDIR)/debian/ucspi-tcp-ipv6 +ifeq (,$(filter terse,$(DEB_BUILD_OPTIONS))) +export V=1 +endif + %: dh $@ +# The upstream Makefile does not have a 'clean' target, so simulate one. +override_dh_auto_clean: + sed -nre '/^[a-z0-9@_.-]+:/ { s/:.*//; p; }' Makefile | grep -Fxve it -e default | xargs -r rm -f + override_dh_auto_configure: + echo '/usr' > conf-home + echo '$(CC) $(CFLAGS) $(CPPFLAGS)' > conf-cc + echo '$(CC) $(LDFLAGS)' > conf-ld +ifeq ($(DEBEMAIL),<salsa-pipeline@debian.org>) + echo 'int main () { return 0; }' > chkshsgr.c +endif mkdir ipv6 xargs install -t ipv6 < FILES cd ipv6 && patch -p1 < ../debian/ipv6-support.patch - echo '/usr' | tee conf-home ipv6/conf-home - echo '$(CC) $(CFLAGS) $(CPPFLAGS)' | tee ipv6/conf-cc conf-cc - echo '$(CC) $(LDFLAGS)' | tee ipv6/conf-ld conf-ld override_dh_auto_build: $(MAKE) DESTDIR=$(PREFIX_IPV4) @@ -37,5 +50,11 @@ override_dh_auto_install: # and `install'. # # So automatic invocation of tests is inhibited, and they are invoked -# manuall at `install' stage. +# manually at `install' stage. +# +# We run our own test suite instead, one that will also be used in +# the autopkgtest later. override_dh_auto_test: + env PYTHONPATH='$(CURDIR)/debian/tests/python' python3 -B -u -m ucspi_tcp_test -d '$(CURDIR)' -p tcp + env PYTHONPATH='$(CURDIR)/debian/tests/python' python3 -B -u -m ucspi_tcp_test -d '$(CURDIR)/ipv6' -p tcp -i 4 + env PYTHONPATH='$(CURDIR)/debian/tests/python' python3 -B -u -m ucspi_tcp_test -d '$(CURDIR)/ipv6' -p tcp -i 6 diff --git a/debian/tests/config/ruff/all.toml b/debian/tests/config/ruff/all.toml new file mode 100644 index 0000000..d4b8f53 --- /dev/null +++ b/debian/tests/config/ruff/all.toml @@ -0,0 +1,5 @@ +extend = "base.toml" +preview = true + +[lint] +select = ["ALL"] diff --git a/debian/tests/config/ruff/base.toml b/debian/tests/config/ruff/base.toml new file mode 100644 index 0000000..4d29037 --- /dev/null +++ b/debian/tests/config/ruff/base.toml @@ -0,0 +1,28 @@ +target-version = "py310" +line-length = 100 + +[lint] +select = [] +ignore = [ + # In Debian packages, the copyright information is stored elsewhere + "CPY001", + + # No blank lines before the class docstring, TYVM + "D203", + + # The multi-line docstring summary starts on the same line + "D213", +] + +[lint.isort] +force-single-line = true +known-first-party = ["ucspi_test", "ucspi_unix_test"] +lines-after-imports = 2 +single-line-exclusions = ["typing"] + +[lint.per-file-ignores] +# This is a command-line tool, console output is part of its job +"python/ucspi_tcp_test/__main__.py" = ["T201"] + +# This is a command-line tool, console output is part of its job +"python/ucspi_test/__init__.py" = ["T201"] diff --git a/debian/tests/control b/debian/tests/control new file mode 100644 index 0000000..c4c2723 --- /dev/null +++ b/debian/tests/control @@ -0,0 +1,11 @@ +Test-Command: set -e; for py in $(py3versions -i); do printf -- '\n\n====== %s\n\n' "$py"; env PYTHONPATH="$(pwd)/debian/tests/python" "$py" -B -u -m ucspi_tcp_test -d /usr/bin -p tcp; done +Depends: ucspi-tcp, python3-all, python3-netifaces, python3-utf8-locale +Features: test-name=debian-python-v4 + +Test-Command: set -e; for py in $(py3versions -i); do printf -- '\n\n====== %s\n\n' "$py"; env PYTHONPATH="$(pwd)/debian/tests/python" "$py" -B -u -m ucspi_tcp_test -d /usr/bin -p tcp -i 4; done +Depends: ucspi-tcp-ipv6, python3-all, python3-netifaces, python3-utf8-locale +Features: test-name=debian-python-v6-4 + +Test-Command: set -e; for py in $(py3versions -i); do printf -- '\n\n====== %s\n\n' "$py"; env PYTHONPATH="$(pwd)/debian/tests/python" "$py" -B -u -m ucspi_tcp_test -d /usr/bin -p tcp -i 6; done +Depends: ucspi-tcp-ipv6, python3-all, python3-netifaces, python3-utf8-locale +Features: test-name=debian-python-v6 diff --git a/debian/tests/pyproject.toml b/debian/tests/pyproject.toml new file mode 100644 index 0000000..6f8c7f9 --- /dev/null +++ b/debian/tests/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = [ + "hatchling >= 1.8, < 2", + "hatch-requirements-txt >= 0.3, < 0.5", +] +build-backend = "hatchling.build" + +[project] +name = "uctest" +description = "Run some tests for the UCSPI client and server tools" +requires-python = ">= 3.10" +license = {"text" = "public domain"} +urls = {"Source" = "https://salsa.debian.org/debian/ucspi-tcp"} +dynamic = ["dependencies", "version"] + +[[project.authors]] +name = "Peter Pentchev" +email = "roam@debian.org" + +[project.scripts] +uctest_tcp = "ucspi_tcp_test.__main__:main" + +[tool.hatch.build.targets.wheel] +packages = ["python/ucspi_test", "python/ucspi_tcp_test"] + +[tool.hatch.metadata.hooks.requirements_txt] +files = ["requirements/install.txt"] + +[tool.setuptools.dynamic] +version = {attr = "ucspi_test.__init__.VERSION"} + +[tool.hatch.version] +path = "python/ucspi_test/__init__.py" +pattern = '(?x) ^ VERSION \s* (?: : \s* Final \s* )? = \s* " (?P<version> [^\s"]+ ) " \s* $' + +[tool.mypy] +strict = true + +[tool.test-stages] +stages = [ + "@check and @quick and not @manual", + "@check and not @manual", + "@tests and not @manual", +] diff --git a/debian/tests/python/ucspi_tcp_test/__init__.py b/debian/tests/python/ucspi_tcp_test/__init__.py new file mode 100644 index 0000000..ea9ead4 --- /dev/null +++ b/debian/tests/python/ucspi_tcp_test/__init__.py @@ -0,0 +1 @@ +"""Run a couple of tests for the `ucspi-tcp` tools.""" diff --git a/debian/tests/python/ucspi_tcp_test/__main__.py b/debian/tests/python/ucspi_tcp_test/__main__.py new file mode 100644 index 0000000..5bddf1e --- /dev/null +++ b/debian/tests/python/ucspi_tcp_test/__main__.py @@ -0,0 +1,322 @@ +"""Test the TCP implementation of UCSPI.""" + +from __future__ import annotations + +import argparse +import dataclasses +import enum +import pathlib +import socket +import typing + +import netifaces +import utf8_locale + +import ucspi_test + + +if typing.TYPE_CHECKING: + from typing import Any, Final + + +@dataclasses.dataclass +class InvalidPortNumberError(ucspi_test.RunnerError): + """Could not connect to a TCP socket.""" + + proto: str + portstr: str + err: Exception + + def __str__(self) -> str: + """Provide a human-readable error message.""" + return f"{self.proto}: could not convert {self.portstr!r} to a number: {self.err}" + + +@dataclasses.dataclass +class NoAvailablePortsError(ucspi_test.RunnerError): + """Could not find an available port number.""" + + addr: str + + def __str__(self) -> str: + """Provide a human-readable error message.""" + return f"Could not find a suitable port on {self.addr}" + + +@dataclasses.dataclass +class SocketCreateError(ucspi_test.RunnerError): + """Could not create a TCP socket.""" + + err: Exception + + def __str__(self) -> str: + """Provide a human-readable error message.""" + return f"Could not create a TCP socket: {self.err}" + + +@dataclasses.dataclass +class SocketReuseAddressError(ucspi_test.RunnerError): + """Could not create a TCP socket.""" + + err: Exception + + def __str__(self) -> str: + """Provide a human-readable error message.""" + return f"Could not set the 'reuse address' option on a TCP socket: {self.err}" + + +@dataclasses.dataclass +class SocketBindError(ucspi_test.RunnerError): + """Could not bind a TCP socket.""" + + addr: str + port: int + err: Exception + + def __str__(self) -> str: + """Provide a human-readable error message.""" + return f"Could not bind to {self.addr}:{self.port}: {self.err}" + + +@dataclasses.dataclass +class SocketListenError(ucspi_test.RunnerError): + """Could not listen on a TCP socket.""" + + addr: str + port: int + err: Exception + + def __str__(self) -> str: + """Provide a human-readable error message.""" + return f"Could not listen on {self.addr}:{self.port}: {self.err}" + + +@dataclasses.dataclass +class SocketConnectError(ucspi_test.RunnerError): + """Could not connect to a TCP socket.""" + + addr: str + port: int + err: Exception + + def __str__(self) -> str: + """Provide a human-readable error message.""" + return f"Could not connect a TCP socket to {self.addr}:{self.port}: {self.err}" + + +@dataclasses.dataclass(frozen=True) +class Config(ucspi_test.Config): + """Runtime configuration for the TCP test runner.""" + + listen_addr: str + listen_addr_len: set[int] + listen_family: socket.AddressFamily + + +class TcpRunner(ucspi_test.Runner): + """Run ucspi-tcp tests.""" + + def find_listening_address(self) -> list[str]: + """Find a local address/port combination.""" + print(f"{self.proto}.find_listening_address() starting") + for port in range(6502, 8086): + if not isinstance(self.cfg, Config): + raise TypeError(repr(self.cfg)) + addr = self.cfg.listen_addr + sock = socket.socket(self.cfg.listen_family, socket.SOCK_STREAM, socket.IPPROTO_TCP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((addr, port)) + print(f"- got {port}") + sock.close() + return [addr, str(port)] + except OSError: + pass + + raise NoAvailablePortsError(addr) + + def get_listening_socket(self, addr: list[str]) -> socket.socket: + """Start listening on the specified address.""" + if not isinstance(self.cfg, Config): + raise TypeError(repr(self.cfg)) + if len(addr) not in self.cfg.listen_addr_len: + raise ucspi_test.SocketAddressLengthError(self.proto, addr) + laddr: Final = addr[0] + try: + lport: Final = int(addr[1]) + except ValueError as err: + raise InvalidPortNumberError(self.proto, addr[1], err) from err + + try: + sock: Final = socket.socket( + self.cfg.listen_family, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + ) + except OSError as err: + raise SocketCreateError(err) from err + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + except OSError as err: + raise SocketReuseAddressError(err) from err + try: + sock.bind((laddr, lport)) + except OSError as err: + raise SocketBindError(laddr, lport, err) from err + try: + sock.listen(5) + except OSError as err: + raise SocketListenError(laddr, lport, err) from err + + return sock + + def get_connected_socket(self, addr: list[str]) -> socket.socket: + """Connect to the specified address.""" + if not isinstance(self.cfg, Config): + raise TypeError(repr(self.cfg)) + if len(addr) not in self.cfg.listen_addr_len: + raise ucspi_test.SocketAddressLengthError(self.proto, addr) + laddr: Final = addr[0] + try: + lport: Final = int(addr[1]) + except ValueError as err: + raise InvalidPortNumberError(self.proto, addr[1], err) from err + + try: + sock: Final = socket.socket( + self.cfg.listen_family, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + ) + except OSError as err: + raise SocketCreateError(err) from err + try: + sock.connect((laddr, lport)) + except OSError as err: + raise SocketConnectError(laddr, lport, err) from err + + return sock + + def format_local_addr(self, addr: list[str]) -> str: + """Format an address returned by accept(), etc.""" + if not isinstance(self.cfg, Config): + raise TypeError(repr(self.cfg)) + if len(addr) not in self.cfg.listen_addr_len: + raise TypeError(repr(addr)) + return f"{addr[0]}:{addr[1]}" + + def format_remote_addr(self, addr: Any) -> str: # noqa: ANN401 + """Format an address returned by accept(), etc.""" + if not isinstance(self.cfg, Config): + raise TypeError(repr(self.cfg)) + if ( + not isinstance(addr, tuple) + or len(addr) not in self.cfg.listen_addr_len + or not isinstance(addr[0], str) + or not isinstance(addr[1], int) + ): + raise TypeError(repr(addr)) + return f"{addr[0]}:{addr[1]}" + + +class IPVersion(str, enum.Enum): + """The IP address family for the listening socket.""" + + IPV4: Final = "4" + IPV6: Final = "6" + + def __str__(self) -> str: + """Return the string value itself.""" + return self.value + + def addr_len(self) -> set[int]: + """Obtain the expected length of an address/port tuple.""" + match self: + case IPVersion.IPV4: + return {2} + + case IPVersion.IPV6: + return {2, 4} + + def family(self) -> socket.AddressFamily: + """Obtain the address family corresponding to this value.""" + match self: + case IPVersion.IPV4: + return socket.AF_INET + + case IPVersion.IPV6: + return socket.AF_INET6 + + +def get_listen_address(ip_version: IPVersion) -> tuple[str, set[int], socket.AddressFamily] | None: + """Get a loopback address for the specified address family, if any are configured.""" + ifaces: Final = netifaces.interfaces() + if "lo" not in ifaces: + print("No 'lo' interface at all?!") + return None + + family: Final = ip_version.family() + addrs: Final = netifaces.ifaddresses("lo") + candidates: Final = addrs.get(family) + if not candidates: + print("No addresses for the specified family on the 'lo' interface") + return None + + return candidates[0]["addr"], ip_version.addr_len(), family + + +def parse_args() -> Config | None: + """Parse the command-line arguments.""" + parser: Final = argparse.ArgumentParser(prog="uctest") + + parser.add_argument( + "-d", + "--bindir", + type=pathlib.Path, + required=True, + help="the path to the UCSPI utilities", + ) + parser.add_argument( + "-i", + "--ip-version", + type=IPVersion, + default=IPVersion.IPV4, + help="the address family to listen on ('4' for IPv4, '6' for IPv6)", + choices=["4", "6"], + ) + parser.add_argument( + "-p", + "--proto", + type=str, + required=True, + help="the UCSPI protocol ('tcp', 'unix', etc)", + ) + args: Final = parser.parse_args() + + listen_data: Final = get_listen_address(args.ip_version) + if listen_data is None: + return None + + return Config( + bindir=args.bindir.absolute(), + listen_addr=listen_data[0], + listen_addr_len=listen_data[1], + listen_family=listen_data[2], + proto=args.proto, + utf8_env=utf8_locale.UTF8Detect().detect().env, + ) + + +def main() -> None: + """Parse command-line arguments, run the tests.""" + cfg: Final = parse_args() + if cfg is None: + print("No loopback interface addresses for the requested family") + return + + ucspi_test.add_handler("tcp", TcpRunner) + ucspi_test.run_test_handler(cfg) + + +if __name__ == "__main__": + main() diff --git a/debian/tests/python/ucspi_tcp_test/py.typed b/debian/tests/python/ucspi_tcp_test/py.typed new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/debian/tests/python/ucspi_tcp_test/py.typed diff --git a/debian/tests/python/ucspi_test/__init__.py b/debian/tests/python/ucspi_test/__init__.py new file mode 100644 index 0000000..6aac8a2 --- /dev/null +++ b/debian/tests/python/ucspi_test/__init__.py @@ -0,0 +1,514 @@ +"""Run a couple of UCSPI client and server tests.""" + +from __future__ import annotations + +import abc +import dataclasses +import pathlib +import shlex +import subprocess # noqa: S404 +import sys +import typing + + +if typing.TYPE_CHECKING: + import socket + from collections.abc import Callable + from typing import Any, Final + + +VERSION: Final = "0.2.0" + +MSG_RESP_HELLO: Final = "a01 hello\n" +MSG_RESP_BYE: Final = "a02 bye\n" + + +@dataclasses.dataclass(frozen=True) +class Config: + """Runtime configuration for the UCSPI test runner.""" + + bindir: pathlib.Path + proto: str + utf8_env: dict[str, str] + + +@dataclasses.dataclass +class RunnerError(Exception): + """An error that occurred while preparing for or running the tests.""" + + +@dataclasses.dataclass +class HandlerMismatchError(RunnerError): + """The test framework attempted to add a different handler.""" + + proto: str + current: type[Runner] + runner: type[Runner] + + def __str__(self) -> str: + """Provide a human-readable error message.""" + return ( + f"Handler mismatch for the {self.proto!r} protocol: " + "had {self.current!r}, now {self.runner!r}" + ) + + +@dataclasses.dataclass +class SocketAddressLengthError(RunnerError): + """An address with an unexpected length was specified.""" + + proto: str + addr: Any + + def __str__(self) -> str: + """Provide a human-readable error message.""" + return f"{self.proto}.get_connected_socket(): unexpected address length for {self.addr!r}" + + +class Runner(abc.ABC): + """A helper class for running tests for a single UCSPI protocol.""" + + _cfg: Config + _proto: str + + def __init__(self, cfg: Config, proto: str) -> None: + """Store the configuration object.""" + self._cfg = cfg + self._proto = proto + + @property + def cfg(self) -> Config: + """Get the configuration for this runner.""" + return self._cfg + + @property + def proto(self) -> str: + """Get the name of the UCSPI protocol to test.""" + return self._proto + + @property + def supports_remote_info(self) -> bool: + """Whether the client and server support the -R command-line option.""" + return True + + @property + def logs_to_stdout(self) -> bool: + """Whether verbose output goes to the standard output stream (argh).""" + return False + + @abc.abstractmethod + def find_listening_address(self) -> list[str]: + """Find an available protocol-specific address to listen on.""" + raise NotImplementedError + + @abc.abstractmethod + def get_listening_socket(self, addr: list[str]) -> socket.socket: + """Start listening on the specified address.""" + raise NotImplementedError(repr(addr)) + + @abc.abstractmethod + def get_connected_socket(self, addr: list[str]) -> socket.socket: + """Connect to the specified address.""" + raise NotImplementedError(repr(addr)) + + @abc.abstractmethod + def format_local_addr(self, addr: list[str]) -> str: + """Format an address returned by accept(), etc.""" + raise NotImplementedError(repr(addr)) + + @abc.abstractmethod + def format_remote_addr(self, addr: Any) -> str: # noqa: ANN401 + """Format an address returned by accept(), etc.""" + raise NotImplementedError(repr(addr)) + + def __repr__(self) -> str: + """Provide a Python-esque representation of the helper.""" + return f"{type(self).__name__}(cfg={self._cfg!r}, proto={self._proto!r})" + + +HANDLERS: dict[str, type[Runner]] = {} + + +def with_listener( + runner: Runner, + addr: list[str], + tag: str, + callback: Callable[[socket.socket], None], +) -> None: + """Get a listener socket from the runner, invoke the callback.""" + proto: Final = runner.proto + saddr: Final = runner.format_local_addr(addr) + print(f"- {tag}: setting up a listening socket at {saddr}") + try: + listener: Final = runner.get_listening_socket(addr) + except RunnerError as err: + sys.exit(f"{tag}: could not get a {proto} listening socket: {err}") + + try: + callback(listener) + finally: + try: + listener.close() + except OSError as err: + print(f"{tag}: could not close the listening socket at {saddr}: {err}", file=sys.stderr) + + +def with_connect( + runner: Runner, + addr: list[str], + tag: str, + callback: Callable[[socket.socket], None], +) -> None: + """Ask the runner to connect to the specified address, invoke the callback.""" + proto: Final = runner.proto + saddr: Final = runner.format_local_addr(addr) + print(f"- {tag}: setting up a socket connected to {saddr}") + try: + conn: Final = runner.get_connected_socket(addr) + except RunnerError as err: + sys.exit(f"{tag}: could not get a {proto} connected socket: {err}") + + try: + callback(conn) + finally: + try: + conn.close() + except OSError as err: + print(f"{tag}: could not close the socket connected to {saddr}: {err}", file=sys.stderr) + + +def with_child( + runner: Runner, + tag: str, + cmd: list[str], + callback: Callable[[subprocess.Popen[str]], None], + stderr: int | None = None, +) -> None: + """Spawn a child process, invoke the callback.""" + print(f"- {tag}: starting {shlex.join(cmd)}") + try: + with subprocess.Popen( + cmd, # noqa: S603 + bufsize=0, + encoding="UTF-8", + env=runner.cfg.utf8_env, + stdout=subprocess.PIPE, + stderr=stderr, + ) as child: + try: + callback(child) + finally: + if child.poll() is None: + ptag: Final = f"the {child.pid} ({cmd[0]}) process" + print(f"- {tag}: {ptag} is still active, killing it") + try: + child.kill() + except OSError as err: + print( + f"{tag}: could not send a kill signal to {ptag}: {err}", + file=sys.stderr, + ) + print(f"- {tag}: waiting for {ptag} to go away") + try: + child.wait() + except OSError as err: + print(f"{tag}: could not wait for {ptag} to end: {err}", file=sys.stderr) + except (OSError, subprocess.CalledProcessError) as err: + sys.exit(f"Could not spawn {shlex.join(cmd)}: {err}") + + +def test_local_spew(runner: Runner, addr: list[str], tag: str, cmd: list[str]) -> None: + """Test a client program against our listening socket, unidirectional transfer.""" + + def run(listener: socket.socket, catproc: subprocess.Popen[str]) -> None: # noqa: PLR0912 + """Run the spew test itself.""" + saddr: Final = runner.format_local_addr(addr) + print(f"- started the client as pid {catproc.pid}") + print(f"- waiting for a connection at {saddr}") + try: + conn, rem_addr = listener.accept() + except OSError as err: + sys.exit(f"Could not accept an incoming connection at {saddr}: {err}") + print( + f"- accepted a connection at fd {conn.fileno()} from " + f"{runner.format_remote_addr(rem_addr)}", + ) + + msg: Final = MSG_RESP_HELLO + MSG_RESP_BYE + try: + conn.sendall(msg.encode("UTF-8")) + except OSError as err: + sys.exit(f"Could not send a message on the {conn} connection at {saddr}: {err}") + + print("- closing the incoming connection") + try: + conn.close() + except OSError as err: + sys.exit(f"Could not close the {conn} connection at {saddr}: {err}") + + try: + output, _ = catproc.communicate() + except OSError as err: + sys.exit(f"Could not read the output of the {cmd[0]} process: {err}") + if not isinstance(output, str): + raise TypeError(repr(output)) + + try: + res: Final = catproc.wait() + except OSError as err: + sys.exit(f"Could not wait for the {cmd[0]} process to end: {err}") + print(f"- client exit code: {res}; output: {output!r}") + if res != 0: + sys.exit(f"The {cmd[0]} program exited with non-zero code {res}") + + if output != msg: + sys.exit(f"Expected {msg!r} as {cmd[0]} output, got {catproc.stdout!r}") + + with_listener( + runner, + addr, + tag, + lambda listener: with_child(runner, tag, cmd, lambda catproc: run(listener, catproc)), + ) + print(f"- {cmd[0]} seems fine") + + +def test_local_cat(runner: Runner, addr: list[str]) -> None: + """Test the {proto}cat program.""" + proto: Final = runner.proto + print(f"\n=== Testing {proto}cat") + if runner.cfg.bindir != pathlib.Path("/usr/bin"): + print( + f"- test skipped, {proto}cat will probably not find {runner.cfg.bindir}/{proto}client", + ) + return + + catpath: Final = runner.cfg.bindir / f"{proto}cat" + print(f"- will spawn {proto}cat at {catpath} in a while") + test_local_spew(runner, addr, "test_local_cat", [str(catpath), *addr]) + + +def remote_opt(runner: Runner) -> list[str]: + """Add the -R command-line option if the client and server support it.""" + return ["-R"] if runner.supports_remote_info else [] + + +def test_local_client_spew(runner: Runner, addr: list[str]) -> None: + """Test {proto}client against our own listening socket.""" + proto: Final = runner.proto + print(f"\n=== Testing {proto}client against our own listening socket") + + clipath: Final = runner.cfg.bindir / f"{proto}client" + print(f"- will spawn {proto}client at {clipath} in a while") + test_local_spew( + runner, + addr, + "test_local_client_spew", + [str(clipath), *remote_opt(runner), *addr, "sh", "-c", "set -e; exec <&6; exec cat"], + ) + + +def test_server_local(runner: Runner, addr: list[str]) -> None: # noqa: C901,PLR0915 + """Test {proto}server against our own client socket.""" + + def run(srvproc: subprocess.Popen[str], conn: socket.socket) -> None: # noqa: PLR0912 + """Run the test itself.""" + try: + rem_addr: Final = conn.getpeername() + except OSError as err: + sys.exit(f"getpeername() failed for {conn!r}: {err}") + raddr: Final = runner.format_remote_addr(rem_addr) + print(f"- got a connection at fd {conn.fileno()} to {raddr}") + + print("- reading all the data we can") + data = b"" + while True: + try: + chunk = conn.recv(4096) + except OSError as err: + sys.exit(f"Could not read from the {conn!r} socket: {err}") + print(f"- read {len(chunk)} bytes from the socket") + if not chunk: + break + data += chunk + print(f"- read a total of {len(data)} bytes from the socket") + + try: + output: Final = data.decode("UTF-8") + except ValueError as err: + sys.exit(f"Could not decode {data!r} as valid UTF-8: {err}") + if output != message: + sys.exit(f"Expected {message!r}, got {output!r}") + + if srvproc.poll() is not None: + sys.exit( + f"Did not expect the {proto}server process to have exited: " + f"code {srvproc.returncode}", + ) + print(f"- sending a SIGTERM signal to the {proto}server process") + try: + srvproc.terminate() + except OSError as err: + sys.exit( + f"Could not send a SIGTERM signal to the {proto}server process at " + f"{srvproc.pid}: {err}", + ) + print(f"- waiting for the {proto}server process to exit") + res: Final = srvproc.wait() + if res != 0: + sys.exit(f"The {proto}server process exited with a non-zero code {res}") + + def wait_and_connect(srvproc: subprocess.Popen[str]) -> None: + """Wait for the "ready" message from tcpserver.""" + stream: Final = srvproc.stdout if runner.logs_to_stdout else srvproc.stderr + assert stream is not None, repr(srvproc) # noqa: S101 # mypy needs this + print(f"- awaiting the first 'status' line from {proto}server") + line: Final = stream.readline() + if "server: status: 0/" not in line: + sys.exit( + f"Unexpected first line from {proto}server: expected 'status: 0/N', got {line!r}", + ) + with_connect(runner, addr, "test_server_local", lambda conn: run(srvproc, conn)) + + proto: Final = runner.proto + print(f"\n=== Testing {proto}server against our own listening socket") + + srvpath: Final = runner.cfg.bindir / f"{proto}server" + print(f"- will spawn {proto}server at {srvpath} in a while") + message: Final = MSG_RESP_HELLO + MSG_RESP_BYE + with_child( + runner, + "test_server_local", + [ + "stdbuf", + "-oL", + "-eL", + "--", + str(srvpath), + "-v", + *remote_opt(runner), + *addr, + "printf", + "--", + message.replace("\n", "\\n"), + ], + wait_and_connect, + stderr=None if runner.logs_to_stdout else subprocess.PIPE, + ) + + print(f"- test_server_local: {srvpath} seems fine") + + +def test_server_client_spew(runner: Runner, addr: list[str]) -> None: # noqa: C901 + """Test {proto}server against {proto}client, unidirectional data transfer.""" + + def run(srvproc: subprocess.Popen[str], cliproc: subprocess.Popen[str]) -> None: + """Read the data received by the client.""" + try: + output, _ = cliproc.communicate() + except OSError as err: + sys.exit(f"Could not read the output of the {proto}client process: {err}") + res_cli: Final = cliproc.poll() + print(f"- client exit code {res_cli}; output {output!r}") + if res_cli is None: + sys.exit(f"Expected the {proto}client process to be done by now") + if res_cli != 0: + sys.exit(f"The {proto}client process exited with a non-zero code {res_cli}") + if output != message: + sys.exit(f"Expected {message!r}, got {output!r}") + + if srvproc.poll() is not None: + sys.exit( + f"Did not expect the {proto}server process to have exited: " + f"code {srvproc.returncode}", + ) + print(f"- sending a SIGTERM signal to the {proto}server process") + try: + srvproc.terminate() + except OSError as err: + sys.exit( + f"Could not send a SIGTERM signal to the {proto}server process at " + f"{srvproc.pid}: {err}", + ) + print(f"- waiting for the {proto}server process to exit") + res_srv: Final = srvproc.wait() + if res_srv != 0: + sys.exit(f"The {proto}server process exited with a non-zero code {res_srv}") + + def wait_and_connect(srvproc: subprocess.Popen[str]) -> None: + """Wait for the "ready" message from tcpserver.""" + stream: Final = srvproc.stdout if runner.logs_to_stdout else srvproc.stderr + assert stream is not None, repr(srvproc) # noqa: S101 # mypy needs this + print(f"- awaiting the first 'status' line from {proto}server") + line: Final = stream.readline() + if "server: status: 0/" not in line: + sys.exit( + f"Unexpected first line from {proto}server: expected 'status: 0/N', got {line!r}", + ) + with_child( + runner, + "test_server_client_spew", + [str(clipath), *remote_opt(runner), *addr, "sh", "-c", "set -e; exec <&6; exec cat"], + lambda cliproc: run(srvproc, cliproc), + ) + + proto: Final = runner.proto + print(f"\n=== Testing {proto}server against {proto}client") + + srvpath: Final = runner.cfg.bindir / f"{proto}server" + print(f"- will spawn {proto}server at {srvpath} in a while") + clipath: Final = runner.cfg.bindir / f"{proto}client" + print(f"- will spawn {proto}client at {clipath} in a while") + message: Final = MSG_RESP_HELLO + MSG_RESP_BYE + with_child( + runner, + "test_server_client_spew", + [ + "stdbuf", + "-oL", + "-eL", + "--", + str(srvpath), + "-v", + *remote_opt(runner), + *addr, + "printf", + "--", + message.replace("\n", "\\n"), + ], + wait_and_connect, + stderr=None if runner.logs_to_stdout else subprocess.PIPE, + ) + + print(f"- {srvpath} seems fine") + + +def run_test(runner: Runner) -> None: + """Run a couple of UCSPI tests.""" + addr: Final = runner.find_listening_address() + + test_local_cat(runner, addr) + test_local_client_spew(runner, addr) + + test_server_local(runner, addr) + test_server_client_spew(runner, addr) + + print(f"\n=== The tests for {runner.cfg.proto} passed") + + +def add_handler(proto: str, runner: type[Runner]) -> None: + """Add a UCSPI protocol test runner.""" + current: Final = HANDLERS.get(proto) + if current is None: + HANDLERS[proto] = runner + elif current != runner: + raise HandlerMismatchError(proto, current, runner) + + +def run_test_handler(cfg: Config) -> None: + """Parse command-line arguments, run the tests.""" + hprot: Final = HANDLERS.get(cfg.proto) + if hprot is None: + sys.exit(f"Don't know how to test the {cfg.proto!r} UCSPI protocol") + + run_test(hprot(cfg, cfg.proto)) diff --git a/debian/tests/python/ucspi_test/py.typed b/debian/tests/python/ucspi_test/py.typed new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/debian/tests/python/ucspi_test/py.typed diff --git a/debian/tests/requirements/install.txt b/debian/tests/requirements/install.txt new file mode 100644 index 0000000..620bac7 --- /dev/null +++ b/debian/tests/requirements/install.txt @@ -0,0 +1,2 @@ +netifaces >= 0.10, < 1 +utf8-locale >= 1, < 2 diff --git a/debian/tests/requirements/ruff.txt b/debian/tests/requirements/ruff.txt new file mode 100644 index 0000000..ba884d9 --- /dev/null +++ b/debian/tests/requirements/ruff.txt @@ -0,0 +1 @@ +ruff == 0.2.2 diff --git a/debian/tests/stubs/netifaces.pyi b/debian/tests/stubs/netifaces.pyi new file mode 100644 index 0000000..6d8afcd --- /dev/null +++ b/debian/tests/stubs/netifaces.pyi @@ -0,0 +1,15 @@ +import socket + +from typing import TypedDict + +# Ah well, we can't use NotRequired with Python 3.10, can we now... +class AddressDict(TypedDict): + addr: str + broadcast: str + netmask: str + peer: str + + +def interfaces() -> list[str]: ... + +def ifaddresses(iface: str) -> dict[socket.AddressFamily, list[AddressDict]]: ... diff --git a/debian/tests/tox.ini b/debian/tests/tox.ini new file mode 100644 index 0000000..44143ac --- /dev/null +++ b/debian/tests/tox.ini @@ -0,0 +1,67 @@ +[tox] +envlist = + format + ruff + mypy + functional +isolated_build = True + +[defs] +pyfiles = + python/ucspi_test \ + python/ucspi_tcp_test + +[testenv:format] +skip_install = True +tags = + check + quick +deps = + -r {toxinidir}/requirements/ruff.txt +commands = + ruff check --config config/ruff/base.toml --select=D,I --diff -- {[defs]pyfiles} + ruff format --check --config config/ruff/base.toml --diff -- {[defs]pyfiles} + +[testenv:reformat] +skip_install = True +tags = + format + manual +deps = + -r {toxinidir}/requirements/ruff.txt +commands = + ruff check --config config/ruff/base.toml --select=D,I --fix -- {[defs]pyfiles} + ruff format --config config/ruff/base.toml -- {[defs]pyfiles} + +[testenv:ruff] +skip_install = True +tags = + check + ruff + quick +deps = + -r requirements/ruff.txt +commands = + ruff check --config config/ruff/all.toml --output-format=concise -- {[defs]pyfiles} + +[testenv:mypy] +skip_install = True +tags = + check +deps = + -r {toxinidir}/requirements/install.txt + mypy >= 1, < 2 +setenv = + MYPYPATH = {toxinidir}/stubs +commands = + mypy {[defs]pyfiles} + +[testenv:functional] +tags = + tests +deps = + -r {toxinidir}/requirements/install.txt +setenv = + UCBINDIR = {env:UCBINDIR:{toxinidir}/../..} +commands = + uctest_tcp -d '{env:UCBINDIR}' -p tcp diff --git a/debian/ucspi-tcp-ipv6.install b/debian/ucspi-tcp-ipv6.install new file mode 100644 index 0000000..2461dc6 --- /dev/null +++ b/debian/ucspi-tcp-ipv6.install @@ -0,0 +1 @@ +debian/contrib/httptaild usr/share/doc/ucspi-tcp-ipv6/contrib diff --git a/debian/ucspi-tcp-man/rblsmtpd.1 b/debian/ucspi-tcp-man/rblsmtpd.1 index 9d9ca08..ca73a44 100644 --- a/debian/ucspi-tcp-man/rblsmtpd.1 +++ b/debian/ucspi-tcp-man/rblsmtpd.1 @@ -84,9 +84,7 @@ and .B \-a options. .B rblsmtpd -tries each source in turn until it finds one that lists or anti-lists $TCPREMOTEIP. It also tries an RBL source of rbl.maps.vix.com if you do not supply any -.B -r -options. See http://maps.vix.com/rbl/ for more information about rbl.maps.vix.com. +tries each source in turn until it finds one that lists or anti-lists $TCPREMOTEIP. If you want to run your own RBL source or anti-RBL source for .BR rblsmtpd , diff --git a/debian/ucspi-tcp.install b/debian/ucspi-tcp.install new file mode 100644 index 0000000..4a66742 --- /dev/null +++ b/debian/ucspi-tcp.install @@ -0,0 +1 @@ +debian/contrib/httptaild usr/share/doc/ucspi-tcp/contrib |