diff --git a/CHANGELOG.md b/CHANGELOG.md index b3dfe9b..9e42c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol packets that went around the tool and those the driver dropped, with a red line while either grows. Its files stay yours, and GitHub and Donate print the address instead of starting a browser as root. On the Tools tab, TCP health and Adapters say they are - Windows-only for now, and the row menus grey out Reset this connection and Show TCP health. + Windows-only for now, and the row menus grey out Show TCP health. - **Linux, experimental: Diagnostics speaks of Linux.** Its help explains what stops a session there - capture rights, the kernel, BTF and pydivert - and what to do, and @@ -36,6 +36,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol **Ping this address** works in the row menus. Its tips and help name Windows only where Windows differs. +- **Linux, experimental: Reset this connection works.** On the Connections page it resets TCP + connections over IPv4 and IPv6, as root or with CAP_NET_ADMIN. The program using the + connection gets `ECONNABORTED` and the other end a reset, which heavy loss in a session can + drop on the way, as on Windows. A connection still opening or closing is not reset. Where the + kernel cannot reset connections (WSL's), the entry is greyed out. A connection missing from + the system's list has closed or belongs to another network namespace, such as a container's. + - **Ping on the Tools tab, to see the session's latency and loss arrive.** Type an address or a name and press **Start**: each ping is a row with its round trip and what came back, the last 60 are charted, and the totals say what was lost. The ping goes on while another tab is in diff --git a/README.md b/README.md index 35be8ef..197910e 100644 --- a/README.md +++ b/README.md @@ -214,9 +214,14 @@ exit code 1, as on Windows without administrator rights. Where Linux differs fro `EPERM` usually means a firewall rule on this computer. A zone of an IPv6 address can be the interface's name (`fe80::1%eth0`). TCP health and Adapters say that they work on Windows only for now, and so do the row menu entries that open - them or reset a connection, and the Diagnostics boxes that add the adapters or the proxy to the - report. The Diagnostics help explains the Linux checks: capture rights, the kernel, BTF and - pydivert. + them, and the Diagnostics boxes that add the adapters or the proxy to the report. The + Diagnostics help explains the Linux checks: capture rights, the kernel, BTF and pydivert. +- **"Reset this connection"** on the Connections page works on Linux for TCP over IPv4 and IPv6, + as root or with the capability CAP_NET_ADMIN. The program using the connection gets + `ECONNABORTED`, and the other end gets a reset. It needs a kernel built with + `CONFIG_INET_DIAG_DESTROY`: Ubuntu's is, WSL's is not, and there the entry is greyed out. A + connection that is not in this system's list of connections has closed, or belongs to a + container or another network namespace, and cannot be reset from here. ## The window @@ -256,9 +261,10 @@ monitor is gone it returns to the centre of the current screen. fills the filter fields with one click. **"Reset this connection"** breaks that one connection at once, with or without a session running: the program using it gets an error, and the other end gets a TCP reset. During a session that reset goes through the impairments - like any other packet, so heavy loss can drop it on the way. It works for TCP over IPv4 only - - Windows has no way to reset an IPv6 connection - so the entry is greyed out on other rows, and - it needs administrator rights. **"Show TCP health"** opens **TCP health** on the Tools tab + like any other packet, so heavy loss can drop it on the way. On Windows it works for TCP over + IPv4 only - Windows has no way to reset an IPv6 connection - so the entry is greyed out on other + rows, and it needs administrator rights. On Linux it works for IPv6 too (see the Linux + section). **"Show TCP health"** opens **TCP health** on the Tools tab for that connection (TCP over IPv4 and IPv6), and **"Ping this address"** starts **Ping** on the Tools tab for the row's remote address. The table is **virtualised**: it draws only the rows actually on screen, so scrolling is instant @@ -1602,10 +1608,12 @@ beantester/ the implementation package registry), read only, for the Tools tab tcpestats.py what Windows' TCP stack measures for one connection (EStats, iphlpapi/ctypes), for the Tools tab's TCP health - tcpreset.py reset one TCP connection (iphlpapi/ctypes), for the Connections - page's row menu + tcpreset.py reset one TCP connection (iphlpapi/ctypes; on Linux the kernel's + SOCK_DESTROY), for the Connections page's row menu tcprow.py one TCP connection the way Windows names it: the row layouts and finding it in the socket table, without calling Windows + sockdiag.py the kernel's own list of TCP sockets on Linux (sock_diag over + netlink), read only: where a reset finds the socket it destroys icmp.py one ICMP echo request (iphlpapi/ctypes), for the Tools tab's Ping - the only module that sends something of its own icmpsock.py the same request on Linux, through the system's ping socket or a diff --git a/beantester/capabilities.py b/beantester/capabilities.py index 5eb2a27..cfdfd91 100644 --- a/beantester/capabilities.py +++ b/beantester/capabilities.py @@ -30,8 +30,9 @@ ``reason`` is an i18n key: Windows only for now. A tool ported to Linux may still be missing on one system or another, and then its module says why in its own words (``why_not()``, asked when it is there): Ping, the first one, needs the ping socket -for the user's group or sudo (``icmp.why_not``); Reset will need -``CONFIG_INET_DIAG_DESTROY``, which WSL's kernel does not have. +for the user's group or sudo (``icmp.why_not``). Reset on Linux needs a kernel built +with ``CONFIG_INET_DIAG_DESTROY`` (WSL's is not); it has no words of its own, because +its only reader is a row menu, which greys the entry out - the README says why. """ from types import ModuleType from typing import NamedTuple, Optional diff --git a/beantester/gui/pages/conns.py b/beantester/gui/pages/conns.py index 2c1b87e..5990cd1 100644 --- a/beantester/gui/pages/conns.py +++ b/beantester/gui/pages/conns.py @@ -21,6 +21,7 @@ driver's own filter has been narrowed to the destination. """ +import sys import threading import time import tkinter as tk @@ -130,13 +131,25 @@ def port_cell(port): tcpreset.UNREADABLE: "log.conn_reset_unreadable", tcpreset.NOT_IPV4: "log.conn_reset_not_ipv4", tcpreset.FAILED: "log.conn_reset_failed"} +# Where Linux's words differ: its rights are root's, the rule on states is this +# program's own (tcpreset.RESETS), and a socket missing from the kernel's list may +# belong to a container or another network namespace, not only to the past. +RESET_LOG_LINUX = {**RESET_LOG, tcpreset.GONE: "log.conn_reset_gone_linux", + tcpreset.REFUSED: "log.conn_reset_refused_linux", + tcpreset.NEEDS_ADMIN: "log.conn_reset_needs_root"} + + +def reset_log(platform=None): + """The sentence for each way a reset can end, on this system or ``platform``.""" + return RESET_LOG_LINUX if (platform or sys.platform).startswith("linux") else RESET_LOG def can_reset(row): - """Whether "Reset this connection" acts on ``row``: TCP over IPv4 only - Windows - has no IPv6 form of the call (tcpreset) - and nothing at all on a system without - it (capabilities). The menu greys the entry out by it, and the entry's command - refuses by it, so the two cannot answer differently.""" + """Whether "Reset this connection" acts on ``row``: TCP over the families this + system resets - IPv4 on Windows, which has no IPv6 form of the call, IPv4 and IPv6 + on Linux (tcpreset) - and nothing at all on a system without it (capabilities). + The menu greys the entry out by it, and the entry's command refuses by it, so the + two cannot answer differently.""" return capabilities.available("tcp_reset") and tcpreset.resettable( row.get("proto"), row.get("remote_ip")) @@ -176,7 +189,7 @@ def show_health(app, row): def _reset_and_say(app, target): outcome = tcpreset.reset(target) - app.log(T(RESET_LOG.get(outcome.kind, "log.conn_reset_failed"), + app.log(T(reset_log().get(outcome.kind, "log.conn_reset_failed"), address=f"{target.remote_ip}:{target.remote_port}", state=outcome.state or "?", code=outcome.code, detail=outcome.detail)) diff --git a/beantester/sockdiag.py b/beantester/sockdiag.py new file mode 100644 index 0000000..1ea40fd --- /dev/null +++ b/beantester/sockdiag.py @@ -0,0 +1,165 @@ +"""The kernel's own list of this machine's TCP sockets on Linux - sock_diag over netlink. + +What ``ss`` reads, and what "Reset this connection" needs there: the connection a +Connections row means, found by its ports and far address, and its id in the kernel's +words - with the cookie, the number the kernel gave that one socket. ``tcpreset`` hands +that id back to reset the connection; this module only asks for the list. It is +``tcprow``'s twin: a ``Socket`` carries the fields ``tcprow.matching`` reads, so a row +is found the same way on both systems. + +The layouts are the kernel's (``include/uapi/linux``: ``netlink.h``, ``sock_diag.h``, +``inet_diag.h``), which never change: a netlink header, then ``inet_diag_req_v2`` out and +``inet_diag_msg`` back, each holding an ``inet_diag_sockid`` - ports and addresses in +network byte order, the interface and the cookie in the machine's own. The numbers are +the kernel's too, not Python's names: Python on Windows has no ``AF_NETLINK``, and the +type checker reads this file there. + +MEASURED 2026-10-26 (``internal_tools`` probes ``2026-10-26-linux-reset``: Ubuntu 26.04.1 +with kernel 7.0.0 and WSL with 6.18; ``tests/test_sockdiag.py`` holds that kernel's own +answers): + +* The list needs no rights: an ordinary user gets every socket of the network namespace. +* A socket a program opened as IPv6 and connected to an IPv4 address (Java does that by + default) is only in the IPv6 list, as ``::ffff:192.0.2.1``. Its packets carry + ``192.0.2.1``, and so does its Connections row: the address is written that way here, + while the id keeps the kernel's form - the kernel finds the socket by it. +* Both lists, asked for every state but LISTEN, took 0.2 ms in WSL and about 3 ms on the + VM, where ``psutil.net_connections`` took 6.6 and 19 ms. +""" +import errno +import ipaddress +import socket +import struct +from typing import NamedTuple + +AF_NETLINK = 16 +NETLINK_SOCK_DIAG = 4 +SOCK_DIAG_BY_FAMILY = 20 +NLMSG_ERROR, NLMSG_DONE = 2, 3 +NLM_F_REQUEST, NLM_F_ACK, NLM_F_DUMP = 0x1, 0x4, 0x300 +MSG_TRUNC = 0x20 +LINUX_AF_INET, LINUX_AF_INET6 = 2, 10 +IPPROTO_TCP = 6 +TCP_LISTEN = 10 +EVERY_STATE = 0xFFFFFFFF +# A listener has no far end, so no Connections row can mean one. +CONNECTED = EVERY_STATE & ~(1 << TCP_LISTEN) +NO_COOKIE = (0xFFFFFFFF, 0xFFFFFFFF) # INET_DIAG_NOCOOKIE: "any socket with this id" +SEQ = 1 # one request per socket: the number tells nothing apart +WAIT_S = 2.0 # the kernel answers at once; a silence is a failure +BUFFER = 1 << 16 # a dump datagram is at most 32 KiB (netlink_recvmsg) + +# The kernel's TCP states (``include/net/tcp_states.h``) in ``portmap.TCP_STATES``' words, +# so a state reads the same on both systems. 12 is the kernel's own: a connection request +# not accepted yet. +STATES = {1: "ESTABLISHED", 2: "SYN_SENT", 3: "SYN_RECEIVED", 4: "FIN_WAIT_1", + 5: "FIN_WAIT_2", 6: "TIME_WAIT", 7: "CLOSED", 8: "CLOSE_WAIT", 9: "LAST_ACK", + 10: "LISTEN", 11: "CLOSING", 12: "NEW_SYN_RECV"} + +_HEADER = struct.Struct("=IHHII") # nlmsghdr: length, type, flags, seq, pid +_REQUEST = struct.Struct("=BBBBI") # inet_diag_req_v2 up to its id: family, protocol, + # extensions, pad, states +_PORTS = struct.Struct("!HH") # inet_diag_sockid: source and destination port +_TAIL = struct.Struct("=III") # ... its interface and the two halves of the cookie +_ERROR = struct.Struct("=i") # nlmsgerr and NLMSG_DONE: a negative errno, or 0 +SOCKID = 48 +MESSAGE = 72 # inet_diag_msg; attributes may follow it +_ID_AT = 4 # family, state, timer, retransmits, then the id + + +class Socket(NamedTuple): + """One TCP socket in the kernel's list. The first five fields are what + ``tcprow.matching`` reads, named as ``portmap.SocketRow`` names them.""" + proto: str + local_ip: str + local_port: int + remote_ip: str # an IPv4 address mapped into IPv6 is written as the IPv4 one + remote_port: int + state: str + kernel_family: int # the list it is in: LINUX_AF_INET or LINUX_AF_INET6 + sockid: bytes # inet_diag_sockid as the kernel sent it, the cookie in it + + +def sockid(sport=0, dport=0, src=b"", dst=b"", interface=0, cookie=NO_COOKIE): + """An ``inet_diag_sockid``. ``src`` and ``dst`` are packed addresses, 4 or 16 bytes.""" + return (_PORTS.pack(sport, dport) + src.ljust(16, b"\0") + dst.ljust(16, b"\0") + + _TAIL.pack(interface, *cookie)) + + +def request(kind, flags, family, sid, states=EVERY_STATE): + """One ``inet_diag_req_v2`` for TCP, in its netlink header.""" + body = _REQUEST.pack(family, IPPROTO_TCP, 0, 0, states) + sid + return _HEADER.pack(_HEADER.size + len(body), kind, flags, SEQ, 0) + body + + +def _open(): + nl = socket.socket(AF_NETLINK, socket.SOCK_RAW, NETLINK_SOCK_DIAG) + nl.settimeout(WAIT_S) + return nl + + +def _messages(data): + """The netlink messages in one datagram, as ``(type, payload)``.""" + out = [] + offset = 0 + while offset + _HEADER.size <= len(data): + length, kind = _HEADER.unpack_from(data, offset)[:2] + if length < _HEADER.size or offset + length > len(data): + raise OSError(errno.EBADMSG, "sock_diag: a message longer than its datagram") + out.append((kind, data[offset + _HEADER.size:offset + length])) + offset += (length + 3) & ~3 + return out + + +def exchange(message, opener=_open): + """Send one request; the kernel's answer as ``(type, payload)`` pairs, up to the one + that ends it - NLMSG_DONE after a list, NLMSG_ERROR otherwise (an acknowledgement is + an error of 0). A socket per request, closed before this returns.""" + with opener() as nl: + nl.sendto(message, (0, 0)) + replies: list = [] + while not replies or replies[-1][0] not in (NLMSG_DONE, NLMSG_ERROR): + data, _ancillary, flags, _sender = nl.recvmsg(BUFFER) + if flags & MSG_TRUNC: + raise OSError(errno.EMSGSIZE, "sock_diag: an answer longer than the buffer") + replies.extend(_messages(data)) + return replies + + +def error_of(replies): + """The errno a request's answer ends in: 0 when the kernel did it.""" + kind, payload = replies[-1] + if kind not in (NLMSG_ERROR, NLMSG_DONE) or len(payload) < _ERROR.size: + raise OSError(errno.EBADMSG, f"sock_diag: an answer that ends in type {kind}") + return -_ERROR.unpack_from(payload)[0] + + +def _address(packed): + address = ipaddress.ip_address(packed) + return str(getattr(address, "ipv4_mapped", None) or address) + + +def _socket(payload): + if len(payload) < MESSAGE: + raise OSError(errno.EBADMSG, f"sock_diag: a socket in {len(payload)} bytes") + family, state = payload[0], payload[1] + sid = bytes(payload[_ID_AT:_ID_AT + SOCKID]) + sport, dport = _PORTS.unpack_from(sid) + size = 4 if family == LINUX_AF_INET else 16 + return Socket("TCP", _address(sid[4:4 + size]), sport, _address(sid[20:20 + size]), dport, + STATES.get(state, str(state)), family, sid) + + +def tcp_sockets(talk=exchange): + """Every TCP socket of this network namespace but its listeners, IPv4 and IPv6. + Raises ``OSError`` when the kernel will not list one of the two: a missing socket + would then say nothing about a connection.""" + found = [] + for family in (LINUX_AF_INET, LINUX_AF_INET6): + replies = talk(request(SOCK_DIAG_BY_FAMILY, NLM_F_REQUEST | NLM_F_DUMP, family, + sockid(), CONNECTED)) + code = error_of(replies) + if code: + raise OSError(code, "sock_diag: the kernel refused the list") + found += [_socket(payload) for kind, payload in replies if kind == SOCK_DIAG_BY_FAMILY] + return found diff --git a/beantester/tcpreset.py b/beantester/tcpreset.py index 85ed259..a98fb4a 100644 --- a/beantester/tcpreset.py +++ b/beantester/tcpreset.py @@ -5,7 +5,7 @@ connections at random or all of them for a few seconds, and only while a session runs; this is one chosen connection, at once, idle or not, and after STOP too. -How: ``SetTcpEntry`` with ``MIB_TCP_STATE_DELETE_TCB`` - Microsoft Learn: "the only +How, on Windows: ``SetTcpEntry`` with ``MIB_TCP_STATE_DELETE_TCB`` - Microsoft Learn: "the only state to which a TCP connection can be set". MEASURED 2026-10-14 on this project's machines: on an open connection both ends get a TCP reset at once - the program that owned the socket gets WSAECONNRESET (10054) on its next call, and Windows SENDS a RST @@ -13,11 +13,11 @@ During a session that RST is a packet like any other: with 100% loss on its path it was dropped, and the far end saw nothing. -What it cannot do, documented and measured: +What it cannot do on Windows, documented and measured: * IPv6. There is no IPv6 form - Learn has only ``SetTcpEntry``, which takes the IPv4 - ``MIB_TCPROW``, and ``iphlpapi.dll`` exports no ``SetTcp6Entry`` - so an IPv6 row - is offered nothing (``resettable``). + ``MIB_TCPROW``, and ``iphlpapi.dll`` exports no ``SetTcp6Entry`` - so there an IPv6 + row is offered nothing (``resettable``). * A connection still opening (SYN_SENT) or already closing (TIME_WAIT), or one that is gone: ``SetTcpEntry`` answered 317 to all three in an ELEVATED process. * Without administrator rights it answered 317 too. Learn documents 317 as "running @@ -25,20 +25,44 @@ is taken from the process's token (``winenv.is_admin``), never from the number. A Connections row names the local PORT and the far end, while the call needs the -local ADDRESS as well, so the socket table is read at the click (~1 ms for 178 -sockets, measured) and the one connection that matches is the one reset. None: it +local ADDRESS as well (on Linux the socket's id), so the socket table is read at the +click (~1 ms for 178 sockets on Windows, measured) and the one connection that matches +is the one reset. None: it has closed since the row was drawn. More than one: the same port on two local addresses - nothing is guessed. -``tests/test_no_telemetry.py`` lists ``SetTcpEntry`` among the functions that put -something on the wire, with this module as the one place allowed to call it. The row -layout and the search in the socket table are ``tcprow``'s, shared with "TCP health". +On Linux the kernel resets a connection itself: SOCK_DESTROY, sent through sock_diag +to the socket's id in the kernel's own list (``sockdiag``). MEASURED 2026-10-26 +(``internal_tools`` probes ``2026-10-26-linux-reset``, Ubuntu 26.04.1 with kernel 7.0.0, +WSL with 6.18), and the reason for each rule: + +* On an open connection the program that owned the socket gets ECONNABORTED (103) at + once, even while it waits in ``recv``, and the far end a reset (ECONNRESET). IPv6 + too, and a socket a program opened as IPv6 for an IPv4 address (``sockdiag``). +* 🔴 The kernel looks a connection up by its addresses and ports, and when there is + no such connection it takes the LISTENER on that local port: a server-side + connection that had just closed, destroyed by its numbers alone, took the server + down - every new connection was refused. So the request always carries the + socket's cookie from the list; for a socket that has gone the kernel then answers + ENOENT and touches nothing. +* It destroys a socket in any state, a reset to the far end only in the states of + ``RESETS`` (``tcp_need_reset``): a connection still opening was dropped and nobody + was sent anything. So the rule Windows applies by itself is applied here. +* Without CAP_NET_ADMIN (root has it; so does a program given the capture's three + capabilities) the list reads and the destroy answers EPERM. A kernel built without + ``CONFIG_INET_DIAG_DESTROY`` - WSL's - answers EOPNOTSUPP to every destroy, and to + one that names no socket at all, which is how ``supported`` asks. + +``tests/test_no_telemetry.py`` lists ``SetTcpEntry`` and ``SOCK_DESTROY`` among the +names that put something on the wire, with this module as the one place allowed to use +them. The row layout and the search in the socket table are ``tcprow``'s, shared with +"TCP health"; the kernel's list is ``sockdiag``'s. """ import ctypes import sys from typing import NamedTuple -from . import crashlog, portmap, tcprow, winenv +from . import crashlog, portmap, sockdiag, tcprow, winenv MIB_TCP_STATE_DELETE_TCB = 12 # portmap.socket_rows' name for the one table a reset reads (its ``failed`` list). @@ -48,17 +72,30 @@ RESET = "reset" GONE = "gone" # no such connection in the table any more AMBIGUOUS = "ambiguous" # more than one matches: nothing is guessed -REFUSED = "refused" # Windows said no; ``state`` says what the table showed +REFUSED = "refused" # not in a state it is reset in; ``state`` says which NEEDS_ADMIN = "needs_admin" UNREADABLE = "unreadable" # the socket table could not be read -NOT_IPV4 = "not_ipv4" # IPv6, or not an address at all +NOT_IPV4 = "not_ipv4" # a family this system cannot reset, or not an address FAILED = "failed" # anything else; ``detail`` is the program's own words +# Linux: the kernel's numbers (``include/uapi/linux/sock_diag.h``, ``errno-base.h``). +SOCK_DESTROY = 21 +EPERM, ENOENT = 1, 2 +# The states in which the kernel sends the far end a reset (``tcp_need_reset``), in +# ``sockdiag.STATES``' words. Linux would also drop a connection still opening, or a +# TIME_WAIT entry, and tell nobody: refused here, as Windows refuses them itself. +RESETS = frozenset({"ESTABLISHED", "CLOSE_WAIT", "FIN_WAIT_1", "FIN_WAIT_2", "SYN_RECEIVED"}) +# A destroy that names no socket: port 0 is never bound, and 0 is no socket's cookie +# (the kernel numbers them from 1). ENOENT: this kernel destroys sockets. EOPNOTSUPP: +# it was built without (WSL). Neither needs rights - a destroy is refused for those +# only once a socket is found. +NOBODY = sockdiag.sockid(cookie=(0, 0)) + class Outcome(NamedTuple): kind: str state: str = "" # the TCP state the table showed (REFUSED, NEEDS_ADMIN) - code: int = 0 # what SetTcpEntry answered + code: int = 0 # what SetTcpEntry or SOCK_DESTROY answered detail: str = "" # FAILED: the exception, program text @@ -71,24 +108,29 @@ def row_for(local_ip, target): return tcprow.row_for(tcprow.Connection(local_ip, *target), MIB_TCP_STATE_DELETE_TCB) -def resettable(proto, remote_ip): - """A Connections row Windows can reset: TCP over IPv4 (there is no IPv6 form).""" +def resettable(proto, remote_ip, system=None): + """A Connections row this system can reset: TCP over the families it takes - IPv4 + on Windows (there is no IPv6 form), IPv4 and IPv6 on Linux. ``system``: a system + class or one of its objects; this machine's when left out.""" if proto != "TCP": return False - return tcprow.family(remote_ip) == 4 + return tcprow.family(remote_ip) in (system or _system_class()).FAMILIES def supported(): - """``SetTcpEntry`` is Windows'. Asked through ``capabilities``, like every tool's API. + """Windows has ``SetTcpEntry``; Linux has SOCK_DESTROY when its kernel was built with + it (``_Linux.can_destroy``). Asked through ``capabilities``, like every tool's API. - Without it a reset on Linux reached ``None.SetTcpEntry``: an ``AttributeError`` - reported as FAILED and recorded as a fault of the program (measured 2026-10-20 on - Ubuntu 26.04 and in WSL, as root and not). + Without the question a reset on Linux reached ``None.SetTcpEntry``: an + ``AttributeError`` reported as FAILED and recorded as a fault of the program + (measured 2026-10-20 on Ubuntu 26.04 and in WSL, as root and not). """ - return sys.platform.startswith("win") + if sys.platform.startswith("win"): + return True + return sys.platform.startswith("linux") and _Linux().can_destroy() -# -- the binding --------------------------------------------------------------------- # +# -- Windows ------------------------------------------------------------------------- # def _iphlpapi(): """iphlpapi with ``SetTcpEntry``'s prototype; None off Windows.""" if not sys.platform.startswith("win"): @@ -106,6 +148,7 @@ def _declared(lib): class _System: """What a reset asks of Windows. Everything is injectable, so it runs in tests anywhere. ``rows`` answers as ``portmap.socket_rows`` does: ``(rows, failed)``.""" + FAMILIES = (4,) # SetTcpEntry takes MIB_TCPROW: IPv4 only def __init__(self, iphlpapi=None, rows=None, is_admin=None): self.iphlpapi = iphlpapi if iphlpapi is not None else _iphlpapi() @@ -115,9 +158,62 @@ def __init__(self, iphlpapi=None, rows=None, is_admin=None): def delete(self, row): return int(self.iphlpapi.SetTcpEntry(ctypes.byref(row))) + def end(self, found, target): + """Reset the one connection found. Windows refuses the states it does not reset.""" + code = self.delete(row_for(found.local_ip, target)) + if code == 0: + return Outcome(RESET) + return Outcome(REFUSED if self.is_admin() else NEEDS_ADMIN, found.state, code) + + +# -- Linux --------------------------------------------------------------------------- # +class _Linux: + """What a reset asks of Linux: the kernel's list (``sockdiag``) and SOCK_DESTROY for + the one socket found in it. ``talk`` sends one request as ``sockdiag.exchange`` + does - a test brings its own.""" + FAMILIES = (4, 6) + + def __init__(self, talk=None): + self.talk = talk or sockdiag.exchange + + def rows(self): + try: + return sockdiag.tcp_sockets(self.talk), [] + except OSError as exc: + raise portmap.SocketTableUnavailable("missing", str(exc)) from exc + + def destroy(self, kernel_family, sid): + """SOCK_DESTROY for the socket ``sid`` names; the errno it answered, 0 = done.""" + return sockdiag.error_of(self.talk(sockdiag.request( + SOCK_DESTROY, sockdiag.NLM_F_REQUEST | sockdiag.NLM_F_ACK, kernel_family, sid))) + + def can_destroy(self): + try: + return self.destroy(sockdiag.LINUX_AF_INET, NOBODY) == ENOENT + except OSError: + return False # no sock_diag at all, or netlink refused to this process + + def end(self, found, _target): + """Reset the one socket found, by its id with the cookie in it - never by its + numbers alone, which can find the listener on the port.""" + if found.state not in RESETS: + return Outcome(REFUSED, found.state) + code = self.destroy(found.kernel_family, found.sockid) + if code in (0, ENOENT): + return Outcome(RESET if code == 0 else GONE) # ENOENT: closed since the read + if code == EPERM: + return Outcome(NEEDS_ADMIN, found.state, code) + return Outcome(FAILED, found.state, code, detail=f"SOCK_DESTROY answered errno {code}") + + +def _system_class(): + """The system a reset asks here. Off Windows and Linux it is never reached: + ``supported`` says no first.""" + return _Linux if sys.platform.startswith("linux") else _System + def _reset(target, system): - if not resettable("TCP", target.remote_ip): + if not resettable("TCP", target.remote_ip, system): return Outcome(NOT_IPV4) try: rows, failed = system.rows() @@ -132,17 +228,14 @@ def _reset(target, system): return Outcome(GONE) if len(found) > 1: return Outcome(AMBIGUOUS) - code = system.delete(row_for(found[0].local_ip, target)) - if code == 0: - return Outcome(RESET) - return Outcome(REFUSED if system.is_admin() else NEEDS_ADMIN, found[0].state, code) + return system.end(found[0], target) def reset(target, system=None): """Reset the connection ``target`` names. On a worker; never raises - a click has to end in a sentence, not in a traceback on a thread nobody watches.""" try: - return _reset(target, system or _System()) + return _reset(target, system or _system_class()()) except Exception as exc: crashlog.note(exc, "tcpreset") return Outcome(FAILED, detail=f"{type(exc).__name__}: {exc}") diff --git a/lang/en.json b/lang/en.json index 9ccaf49..3b32087 100644 --- a/lang/en.json +++ b/lang/en.json @@ -300,9 +300,12 @@ "log.conn_reset_done": "Reset the connection to {address}. The program using it gets an error now. The other end gets a reset, unless the impairments drop it on the way.", "log.conn_reset_failed": "Could not reset the connection to {address} ({detail}).", "log.conn_reset_gone": "The connection to {address} has already closed - nothing to reset.", + "log.conn_reset_gone_linux": "The connection to {address} is not in this system's list of connections - it has closed, or it belongs to a container or another network namespace. Nothing was reset.", "log.conn_reset_needs_admin": "Resetting a connection needs administrator rights - nothing was reset.", + "log.conn_reset_needs_root": "Resetting a connection needs root rights (sudo) or the capability CAP_NET_ADMIN - nothing was reset.", "log.conn_reset_not_ipv4": "Only IPv4 TCP connections can be reset - Windows has no way to reset an IPv6 one.", "log.conn_reset_refused": "Windows did not reset the connection to {address} (state {state}). It resets only open connections, not ones still connecting or closing.", + "log.conn_reset_refused_linux": "The connection to {address} was not reset (state {state}). Only open connections are reset, not ones still connecting or closing.", "log.conn_reset_unreadable": "The connection list could not be read - nothing was reset.", "log.conns_export_busy": "An export is already running. Wait for it to finish before starting another.", "log.conns_saved_to": "Connections saved to", diff --git a/lang/pl.json b/lang/pl.json index d76a31a..8ac62a6 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -300,9 +300,12 @@ "log.conn_reset_done": "Zerwano połączenie z {address}. Program, który go używał, dostaje błąd od razu. Druga strona dostaje reset, chyba że psucie odrzuci go po drodze.", "log.conn_reset_failed": "Nie udało się zerwać połączenia z {address} ({detail}).", "log.conn_reset_gone": "Połączenie z {address} jest już zamknięte - nie ma czego zrywać.", + "log.conn_reset_gone_linux": "Połączenia z {address} nie ma na liście połączeń tego systemu - jest już zamknięte albo należy do kontenera lub innej przestrzeni sieciowej. Nic nie zerwano.", "log.conn_reset_needs_admin": "Zerwanie połączenia wymaga uprawnień administratora - nic nie zerwano.", + "log.conn_reset_needs_root": "Zerwanie połączenia wymaga uprawnień roota (sudo) albo zdolności CAP_NET_ADMIN - nic nie zerwano.", "log.conn_reset_not_ipv4": "Zerwać można tylko połączenia TCP po IPv4 - Windows nie ma sposobu na zerwanie połączenia IPv6.", "log.conn_reset_refused": "Windows nie zerwał połączenia z {address} (stan {state}). Zrywa tylko otwarte połączenia, nie te w trakcie łączenia lub zamykania.", + "log.conn_reset_refused_linux": "Nie zerwano połączenia z {address} (stan {state}). Zrywane są tylko otwarte połączenia, nie te w trakcie łączenia lub zamykania.", "log.conn_reset_unreadable": "Nie udało się odczytać listy połączeń - nic nie zerwano.", "log.conns_export_busy": "Eksport już trwa. Poczekaj, aż się skończy, zanim uruchomisz kolejny.", "log.conns_saved_to": "Zapisano połączenia do", diff --git a/lang/zh.json b/lang/zh.json index e92ba7c..5e2a2a5 100644 --- a/lang/zh.json +++ b/lang/zh.json @@ -300,9 +300,12 @@ "log.conn_reset_done": "已重置与 {address} 的连接。使用它的程序会立即收到错误。对端会收到重置,除非弱网设置在途中将其丢弃。", "log.conn_reset_failed": "无法重置与 {address} 的连接({detail})。", "log.conn_reset_gone": "与 {address} 的连接已经关闭,无需重置。", + "log.conn_reset_gone_linux": "与 {address} 的连接不在本系统的连接列表中:它已经关闭,或者属于某个容器或其他网络命名空间。未重置任何连接。", "log.conn_reset_needs_admin": "重置连接需要管理员权限,未重置任何连接。", + "log.conn_reset_needs_root": "重置连接需要 root 权限(sudo)或 CAP_NET_ADMIN 能力,未重置任何连接。", "log.conn_reset_not_ipv4": "只能重置 IPv4 TCP 连接,Windows 无法重置 IPv6 连接。", "log.conn_reset_refused": "Windows 未重置与 {address} 的连接(状态 {state})。它只重置已打开的连接,不重置正在连接或正在关闭的连接。", + "log.conn_reset_refused_linux": "未重置与 {address} 的连接(状态 {state})。只重置已打开的连接,不重置正在连接或正在关闭的连接。", "log.conn_reset_unreadable": "无法读取连接列表,未重置任何连接。", "log.conns_export_busy": "导出已在进行中。请等待其完成后再开始新的导出。", "log.conns_saved_to": "连接列表已保存至", diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index b088d09..fd93461 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -23,7 +23,7 @@ import pytest -from beantester import capabilities, icmp, icmpsock, paths +from beantester import capabilities, icmp, icmpsock, paths, tcpreset from fakes import check ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -113,21 +113,25 @@ def test_the_answer_follows_the_module_when_it_is_asked(monkeypatch): check("and the reason to show", capabilities.why_not("ping") == capabilities.NOT_HERE) -@pytest.mark.parametrize("platform, sockets, ping", [ - ("win32", None, True), ("linux", icmpsock.DGRAM, True), ("linux", icmpsock.RAW, True), - ("linux", None, False)]) -def test_windows_offers_every_capability_and_linux_ping_when_it_may_send(monkeypatch, platform, - sockets, ping): +@pytest.mark.parametrize("platform, sockets, ping, destroys", [ + ("win32", None, True, False), ("linux", icmpsock.DGRAM, True, False), + ("linux", icmpsock.RAW, True, True), ("linux", None, False, True), + ("linux", None, False, False)]) +def test_windows_offers_every_capability_and_linux_ping_when_it_may_send( + monkeypatch, platform, sockets, ping, destroys): """The predicates read ``sys.platform`` when asked; every answer on either runner. Linux has Ping when this process may open an ICMP socket - ``icmpsock.kind``, stood in, because the real one asks this runner's kernel (on Windows it asks Winsock) - - and none of the rest yet.""" + and Reset when its kernel destroys sockets (``tcpreset._Linux.can_destroy``, stood + in for the same reason), and none of the rest yet.""" monkeypatch.setattr(sys, "platform", platform) monkeypatch.setattr(icmpsock, "kind", lambda: sockets) + monkeypatch.setattr(tcpreset._Linux, "can_destroy", lambda _self: destroys) got = {name: capabilities.available(name) for name in capabilities.CAPABILITIES} windows = platform == "win32" - want = {name: windows or (name == "ping" and ping) for name in capabilities.CAPABILITIES} - check(f"{platform}, {sockets}: {want}", got == want, f"({got})") + want = {name: windows or (name == "ping" and ping) or (name == "tcp_reset" and destroys) + for name in capabilities.CAPABILITIES} + check(f"{platform}, {sockets}, {destroys}: {want}", got == want, f"({got})") def test_a_linux_without_the_right_to_ping_says_how_to_get_it(monkeypatch): diff --git a/tests/test_ci_linux_capture_smoke.py b/tests/test_ci_linux_capture_smoke.py index d928c64..ebf6e19 100644 --- a/tests/test_ci_linux_capture_smoke.py +++ b/tests/test_ci_linux_capture_smoke.py @@ -78,6 +78,32 @@ def test_every_broken_ping_through_a_session_is_named(): f"({failures})") +def test_every_broken_reset_is_named(): + plain = {"outcome": "reset", "client": smoke.ECONNABORTED, "far": "reset"} + impaired = {"outcome": "reset", "client": smoke.ECONNABORTED, "far": "nothing"} + stale = {"found": 1, "stale": "gone", "listener": "alive"} + check("a reset that did its job passes", smoke.judge_reset(plain, impaired, stale) == []) + cases = { + "nothing reset": ((dict(plain, outcome="gone"), impaired, stale), "plain reset"), + "the program heard nothing": ((dict(plain, client=None), impaired, stale), "plain reset"), + "the far end got no reset": ((dict(plain, far="nothing"), impaired, stale), "plain reset"), + "the reset got through --loss 100": ((plain, dict(impaired, far="reset"), stale), + "reset under loss"), + "no reset under the session": ((plain, dict(impaired, outcome="failed"), stale), + "reset under loss"), + "a session that never ran": ((plain, {}, stale), "reset under loss"), + "the connection not in the list": ((plain, impaired, dict(stale, found=0)), "stale"), + "a gone connection read as reset": ((plain, impaired, dict(stale, stale="reset")), "stale"), + "the listener taken": ((plain, impaired, dict(stale, listener="ConnectionRefusedError")), + "stale"), + "nothing measured": ((plain, impaired, {}), "stale"), + } + for name, (got, phase) in cases.items(): + failures = smoke.judge_reset(*got) + check(f"{name}: failed, naming {phase}", any(f.startswith(phase) for f in failures), + f"({failures})") + + def test_a_machine_that_cannot_run_it_skips_and_ci_turns_that_into_a_failure(capsys): check("not Linux", smoke.skip_reason("win32", 0, True) == "not Linux") check("not root", "root" in smoke.skip_reason("linux", 1000, True)) diff --git a/tests/test_gui_state.py b/tests/test_gui_state.py index efa6a58..bfc3c04 100644 --- a/tests/test_gui_state.py +++ b/tests/test_gui_state.py @@ -422,7 +422,8 @@ def row(proc): def test_connection_menu_offers_a_reset_only_for_tcp_over_ipv4(): """Windows resets TCP over IPv4 only (``tcpreset``): every other row gets the - entry greyed, the way a row with no process gets "Target this process".""" + entry greyed, the way a row with no process gets "Target this process". Linux + resets TCP over IPv6 too. Each system's rule, on either runner.""" run_gui(""" from beantester import tcpreset from beantester.gui.theme import MENU_ENTRY_INERT, MENU_ENTRY_LIVE @@ -440,15 +441,18 @@ def row(proto, ip): values[keys.index("proto")], values[keys.index("remote_ip")] = proto, ip return values - for proto, ip, state in (("TCP", "198.51.100.7", MENU_ENTRY_LIVE), - ("UDP", "198.51.100.7", MENU_ENTRY_INERT), - ("ICMP", "198.51.100.7", MENU_ENTRY_INERT), - ("TCP", "2001:db8::7", MENU_ENTRY_INERT)): - page.table.sync([("r", row(proto, ip))]) - tree.row_at = page.table._slots[0] - page.table.row_menu_at_pointer(Ev()) - got = page.menu.entry_states[page.RESET_INDEX] - assert got == state, (proto, ip, got) + for system, ipv6 in ((tcpreset._System, MENU_ENTRY_INERT), + (tcpreset._Linux, MENU_ENTRY_LIVE)): + tcpreset._system_class = lambda system=system: system + for proto, ip, state in (("TCP", "198.51.100.7", MENU_ENTRY_LIVE), + ("UDP", "198.51.100.7", MENU_ENTRY_INERT), + ("ICMP", "198.51.100.7", MENU_ENTRY_INERT), + ("TCP", "2001:db8::7", ipv6)): + page.table.sync([("r", row(proto, ip))]) + tree.row_at = page.table._slots[0] + page.table.row_menu_at_pointer(Ev()) + got = page.menu.entry_states[page.RESET_INDEX] + assert got == state, (system.__name__, proto, ip, got) assert page._reset_connection() is None, "a row with no port numbers starts nothing" """) @@ -617,6 +621,21 @@ def fake_reset(target): tcpreset.REFUSED, tcpreset.NEEDS_ADMIN, tcpreset.UNREADABLE, tcpreset.NOT_IPV4, tcpreset.FAILED}, "an outcome with no sentence" + # Linux says three of them in its own words (root, its rule on states, and a + # socket that may belong to another network namespace), the rest as Windows. + linux, windows = conns.reset_log("linux"), conns.reset_log("win32") + assert windows is conns.RESET_LOG, "Windows' sentences changed" + assert set(linux) == set(windows), "an outcome with no Linux sentence" + assert {kind for kind in linux if linux[kind] != windows[kind]} == { + tcpreset.GONE, tcpreset.REFUSED, tcpreset.NEEDS_ADMIN}, linux + from beantester.i18n import T + assert all(T(key) != key for key in linux.values()), "a sentence with no text" + # ... and the worker says the sentence of the system it is on. + conns.reset_log = lambda platform=None: linux + conns.reset_connection(app, row).join(20) + app._logview.drain() + said = T("log.conn_reset_refused_linux", address="198.51.100.7:443", state="TIME_WAIT") + assert said in app._log_lines[-1], (said, app._log_lines[-1:]) """) diff --git a/tests/test_hot_path.py b/tests/test_hot_path.py index b89dce6..2546e64 100644 --- a/tests/test_hot_path.py +++ b/tests/test_hot_path.py @@ -121,6 +121,9 @@ # "Reset this connection" on the Connections page (``tcpreset._System``): one call, on # a click, by a worker - never by a packet thread. RESET_METHODS = ("delete",) +# The same on Linux (``tcpreset._Linux``): the kernel's socket list and one SOCK_DESTROY, +# both over netlink - on a click, by a worker. +LINUX_RESET_METHODS = ("rows", "destroy") # The Tools tab's TCP health (``tcpestats._System``): counting turned on and read, by # that tool's worker once a second - never by a packet thread. ESTATS_METHODS = ("enable", "read_stats") @@ -152,6 +155,8 @@ def os_calls_recorded(): getattr(adapters._Native, name) for name in ADAPTER_METHODS}) native.update({(tcpreset._System, name, "tcpreset." + name): getattr(tcpreset._System, name) for name in RESET_METHODS}) + native.update({(tcpreset._Linux, name, "tcpreset_linux." + name): + getattr(tcpreset._Linux, name) for name in LINUX_RESET_METHODS}) native.update({(tcpestats._System, name, "tcpestats." + name): getattr(tcpestats._System, name) for name in ESTATS_METHODS}) native.update({(icmp._System, name, "icmp." + name): @@ -319,6 +324,18 @@ def SetTcpEntry(self, _row): check("the connection reset is recorded too", names == ["tcpreset.delete"], f"({names})") check("and unwrapped after", tcpreset._System.delete.__name__ == "delete") + # And Linux's: the list and a destroy, through a kernel that answers "done". + def kernel(message): + return [(2, bytes(4) + message[:16])] # NLMSG_ERROR 0: listed nothing, done + with os_calls_recorded() as calls: + system = tcpreset._Linux(talk=kernel) + system.rows() + system.destroy(2, bytes(48)) + names = [name for name, _ in calls] + check("Linux's reset is recorded too", + names == ["tcpreset_linux.rows", "tcpreset_linux.destroy"], f"({names})") + check("and unwrapped after", tcpreset._Linux.destroy.__name__ == "destroy") + # And TCP health's: counting turned on and read, through a fake binding. class Estats: def SetPerTcpConnectionEStats(self, *_args): diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index 0e610d6..3709526 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -3246,11 +3246,11 @@ "test": "test_every_tool_needs_a_real_capability_and_the_one_its_logic_imports", }, { - "label": "capabilities: a reset is said to exist off Windows", + "label": "capabilities: a reset is said to exist off Windows without asking the kernel", "file": "beantester/tcpreset.py", - "old": " Ubuntu 26.04 and in WSL, as root and not).\n \"\"\"\n" - " return sys.platform.startswith(\"win\")\n", - "new": " Ubuntu 26.04 and in WSL, as root and not).\n \"\"\"\n return True\n", + "old": " if sys.platform.startswith(\"win\"):\n return True\n" + " return sys.platform.startswith(\"linux\") and _Linux().can_destroy()\n", + "new": " return True\n", "test": "test_windows_offers_every_capability_and_linux_ping_when_it_may_send", }, { @@ -8365,8 +8365,8 @@ { "label": "tcpreset: an IPv6 row is offered a reset Windows cannot do", "file": "beantester/tcpreset.py", - "old": " return tcprow.family(remote_ip) == 4\n", - "new": " return tcprow.family(remote_ip) in (4, 6)\n", + "old": " FAMILIES = (4,) # SetTcpEntry takes MIB_TCPROW: IPv4 only\n", + "new": " FAMILIES = (4, 6)\n", "test": "test_only_tcp_over_ipv4_is_offered", }, { @@ -8568,21 +8568,21 @@ { "label": "tcpreset: a refused reset reads as done", "file": "beantester/tcpreset.py", - "old": " if code == 0:\n", - "new": " if code in (0, 317):\n", + "old": " if code == 0:\n", + "new": " if code in (0, 317):\n", "test": "test_a_refusal_is_named_by_the_token_not_by_the_number", }, { "label": "tcpreset: the reason for a refusal is read from the number", "file": "beantester/tcpreset.py", - "old": " return Outcome(REFUSED if system.is_admin() else NEEDS_ADMIN, found[0].state, code)\n", - "new": " return Outcome(NEEDS_ADMIN if code == 317 else REFUSED, found[0].state, code)\n", + "old": " return Outcome(REFUSED if self.is_admin() else NEEDS_ADMIN, found.state, code)\n", + "new": " return Outcome(NEEDS_ADMIN if code == 317 else REFUSED, found.state, code)\n", "test": "test_a_refusal_is_named_by_the_token_not_by_the_number", }, { "label": "tcpreset: an IPv6 target reads the socket table", "file": "beantester/tcpreset.py", - "old": " if not resettable(\"TCP\", target.remote_ip):\n", + "old": " if not resettable(\"TCP\", target.remote_ip, system):\n", "new": " if False:\n", "test": "test_what_cannot_be_asked_ends_in_an_outcome_never_a_raise", }, @@ -8607,6 +8607,113 @@ "new": " except OSError as exc:\n", "test": "test_what_cannot_be_asked_ends_in_an_outcome_never_a_raise", }, + { + # MEASURED 2026-10-26: by its numbers alone the kernel took the LISTENER. + "label": "tcpreset: Linux destroys a socket by its numbers, not its cookie", + "file": "beantester/tcpreset.py", + "old": " code = self.destroy(found.kernel_family, found.sockid)\n", + "new": " code = self.destroy(found.kernel_family, found.sockid[:40] + bytes([255]) * 8)\n", + "test": "test_linux_resets_the_one_socket_by_its_id_with_the_cookie_in_it", + }, + { + # Without NLM_F_ACK a destroy that worked is answered with nothing at all. + "label": "tcpreset: Linux's destroy asks for no answer", + "file": "beantester/tcpreset.py", + "old": " SOCK_DESTROY, sockdiag.NLM_F_REQUEST | sockdiag.NLM_F_ACK, kernel_family, sid)))\n", + "new": " SOCK_DESTROY, sockdiag.NLM_F_REQUEST, kernel_family, sid)))\n", + "test": "test_linux_resets_the_one_socket_by_its_id_with_the_cookie_in_it", + }, + { + "label": "tcpreset: Linux resets a connection still opening or closing", + "file": "beantester/tcpreset.py", + "old": " if found.state not in RESETS:\n", + "new": " if False:\n", + "test": "test_linux_resets_only_where_the_other_end_gets_a_reset", + }, + { + "label": "tcpreset: a kernel without SOCK_DESTROY is said to have one", + "file": "beantester/tcpreset.py", + "old": " return self.destroy(sockdiag.LINUX_AF_INET, NOBODY) == ENOENT\n", + "new": " return self.destroy(sockdiag.LINUX_AF_INET, NOBODY) in (ENOENT, 95)\n", + "test": "test_linux_has_a_reset_when_its_kernel_destroys_sockets", + }, + { + "label": "tcpreset: on Linux a missing right reads as a failure", + "file": "beantester/tcpreset.py", + "old": " if code == EPERM:\n", + "new": " if False:\n", + "test": "test_linux_names_each_answer_of_the_kernel", + }, + { + "label": "tcpreset: on Linux a socket gone since the list reads as reset", + "file": "beantester/tcpreset.py", + "old": " return Outcome(RESET if code == 0 else GONE) # ENOENT: closed since the read\n", + "new": " return Outcome(RESET)\n", + "test": "test_linux_names_each_answer_of_the_kernel", + }, + { + "label": "tcpreset: on Linux a list the kernel refused reads as a closed connection", + "file": "beantester/tcpreset.py", + "old": " raise portmap.SocketTableUnavailable(\"missing\", str(exc)) from exc\n", + "new": " return [], []\n", + "test": "test_linux_guesses_nothing_and_reads_no_missing_list_as_closed", + }, + { + "label": "sockdiag: a socket opened as IPv6 for an IPv4 address keeps the mapped form", + "file": "beantester/sockdiag.py", + "old": " return str(getattr(address, \"ipv4_mapped\", None) or address)\n", + "new": " return str(address)\n", + "test": "test_linux_finds_a_socket_opened_as_ipv6_for_an_ipv4_address", + }, + { + "label": "sockdiag: the IPv6 list is never asked for", + "file": "beantester/sockdiag.py", + "old": " for family in (LINUX_AF_INET, LINUX_AF_INET6):\n", + "new": " for family in (LINUX_AF_INET,):\n", + "test": "test_the_kernels_own_list_reads_as_the_sockets_said_of_themselves", + }, + { + "label": "sockdiag: ports are read in the machine's byte order", + "file": "beantester/sockdiag.py", + "old": "_PORTS = struct.Struct(\"!HH\")", + "new": "_PORTS = struct.Struct(\"=HH\")", + "test": "test_the_kernels_own_list_reads_as_the_sockets_said_of_themselves", + }, + { + "label": "sockdiag: a cut answer is read as the whole list", + "file": "beantester/sockdiag.py", + "old": " if flags & MSG_TRUNC:\n", + "new": " if False:\n", + "test": "test_what_does_not_add_up_is_an_error_never_a_shorter_list", + }, + { + "label": "sockdiag: a message cut off by its datagram is read as a shorter one", + "file": "beantester/sockdiag.py", + "old": " if length < _HEADER.size or offset + length > len(data):\n", + "new": " if length < _HEADER.size:\n", + "test": "test_what_does_not_add_up_is_an_error_never_a_shorter_list", + }, + { + "label": "sockdiag: a list the kernel refused reads as an empty one", + "file": "beantester/sockdiag.py", + "old": " if code:\n", + "new": " if False:\n", + "test": "test_a_list_the_kernel_refuses_is_an_error_for_both_families", + }, + { + "label": "conns: Linux is told Windows' sentences", + "file": "beantester/gui/pages/conns.py", + "old": " return RESET_LOG_LINUX if (platform or sys.platform).startswith(\"linux\") else RESET_LOG\n", + "new": " return RESET_LOG\n", + "test": "test_a_connection_reset_runs_off_the_ui_thread_and_says_how_it_ended", + }, + { + "label": "conns: a reset's sentence is not the system's own", + "file": "beantester/gui/pages/conns.py", + "old": " app.log(T(reset_log().get(outcome.kind, \"log.conn_reset_failed\"),\n", + "new": " app.log(T(RESET_LOG.get(outcome.kind, \"log.conn_reset_failed\"),\n", + "test": "test_a_connection_reset_runs_off_the_ui_thread_and_says_how_it_ended", + }, { "label": "conns: the row menu offers a reset for a row Windows cannot reset", "file": "beantester/gui/pages/conns.py", diff --git a/tests/test_no_telemetry.py b/tests/test_no_telemetry.py index 8692673..49dccdb 100644 --- a/tests/test_no_telemetry.py +++ b/tests/test_no_telemetry.py @@ -118,6 +118,8 @@ "Ping's name lookup (getaddrinfo, below) and the address family constants", ("beantester/icmpsock.py", "socket"): "Ping on Linux: the ICMP socket an echo request goes through (below)", + ("beantester/sockdiag.py", "socket"): + "Linux: the netlink socket to the kernel's own list of TCP sockets (below)", ("beantester/gui/links.py", "webbrowser"): "the page buttons (support page, GitHub page), opened on a click", ("beantester/winenv.py", "subprocess"): @@ -169,6 +171,11 @@ ("beantester/icmpsock.py", "socket", "if_nametoindex"): "the number of a named interface, for an IPv6 zone typed as a name " "(fe80::1%eth0): a lookup in this machine's own interface list, nothing sent", + ("beantester/sockdiag.py", "socket", "socket"): + "a netlink socket (AF_NETLINK, sock_diag): it talks to this machine's kernel and " + "to nothing else - the list of TCP sockets, and for tcpreset the one SOCK_DESTROY " + "a reset is. Opened per request and closed before it returns. What a destroy " + "makes the kernel send is SOCK_DESTROY's entry in WIRE_FUNCTIONS", ("beantester/gui/links.py", "webbrowser", "open_new_tab"): "opens the support page or the project's GitHub page in the user's browser, " "only when they click its button (header or About window)", @@ -222,6 +229,13 @@ # TCB made Windows send a RST to the far end - seen there 1 ms later over a real # adapter, and over loopback. # +# SOCK_DESTROY is Linux's, and a number in a netlink request rather than a function +# (``include/uapi/linux/sock_diag.h``) - matched as a NAME too, which is how a module +# spells a constant of the kernel's. MEASURED 2026-10-26 on Ubuntu 26.04: destroying an +# open connection's socket made the kernel send a RST to the far end (ECONNRESET there). +# A module that writes the bare number 21 walks past this, as a module that writes an +# ordinal past ``iphlpapi`` would: a name list is a floor, not a proof. +# # winhttp's, from Microsoft Learn (read 2026-10-14): WinHttpSendRequest "sends the # specified request to the HTTP server", WinHttpWriteData "writes request data to # an HTTP server", the WebSocket send, shutdown ("sends a close frame") and close; @@ -247,6 +261,7 @@ "WinHttpGetProxyForUrlEx": "the same, asynchronous form", "WinHttpDetectAutoProxyConfigUrl": "proxy discovery over DHCP and DNS", "SetTcpEntry": "a TCP reset to the far end of the connection it deletes", + "SOCK_DESTROY": "on Linux, a TCP reset to the far end of the connection whose socket it destroys", } # The one place each sending function may be named, with why that is the product @@ -266,6 +281,8 @@ WIRE_ALLOWED = { ("beantester/tcpreset.py", "SetTcpEntry"): "resets the one connection the tester chose in the Connections row menu", + ("beantester/tcpreset.py", "SOCK_DESTROY"): + "the same reset on Linux (2026-10-26): that one connection's socket, by its id", ("beantester/icmp.py", "IcmpSendEcho2"): "Ping on the Tools tab: an IPv4 echo request to the typed address, Start to Stop", ("beantester/icmp.py", "Icmp6SendEcho2"): @@ -429,6 +446,21 @@ def _string_findings(node, docstrings): return out +def _wire_name(node): + """The sending name ``node`` spells, if any. A function reached as an attribute, off + whatever holds the library - a loader chain or a handle stored in an instance; + matching the attribute alone is what covers the handle case, which no static scan + can trace. Or a plain name: a constant a module holds of the kernel's, the way + Linux's SOCK_DESTROY is held.""" + if isinstance(node, ast.Attribute): + name = node.attr + elif isinstance(node, ast.Name): + name = node.id + else: + return None + return name if name in WIRE_FUNCTIONS else None + + def findings(source, rel): """Every network finding in one module: a list of ``(kind, detail, line)``. @@ -470,11 +502,9 @@ def findings(source, rel): if owner in ("subprocess", "os") and name in SPAWN_CALLS: found.append(("spawn", "%s.%s" % (owner, name), node.lineno)) found.extend(_string_findings(node, docstrings)) - # A function reached as an attribute, off whatever holds the library - a - # loader chain or a handle stored in an instance. Matching the attribute - # alone is what covers the handle case, which no static scan can trace. - if isinstance(node, ast.Attribute) and node.attr in WIRE_FUNCTIONS: - found.append(("wire", node.attr, node.lineno)) + wire = _wire_name(node) + if wire: + found.append(("wire", wire, node.lineno)) library = _library_name(node) if library: found.append(("library", library.lower().removesuffix(".dll"), @@ -572,9 +602,13 @@ def test_the_wire_allowlist_names_a_place_not_a_function(): caught, and the allowed place is not let off for a different function.""" reset = "import ctypes\nlib = ctypes.WinDLL('iphlpapi.dll')\nlib.SetTcpEntry(row)\n" ping = "import ctypes\nctypes.windll.iphlpapi.IcmpSendEcho2(h, 0, 0, 0, 0, 0, 0, 0, 0, 0)\n" + # Linux's reset: a constant of the kernel's, named where it is set and where it is used. + destroy = "SOCK_DESTROY = 21\nrequest(SOCK_DESTROY, 5, 2, sid)\n" cases = {"the allowed call in its own module": (reset, "beantester/tcpreset.py", False), "the same call in any other module": (reset, "beantester/gui/pages/conns.py", True), - "a ping in the allowed module": (ping, "beantester/tcpreset.py", True)} + "a ping in the allowed module": (ping, "beantester/tcpreset.py", True), + "Linux's destroy in its own module": (destroy, "beantester/tcpreset.py", False), + "the destroy anywhere else": (destroy, "beantester/sockdiag.py", True)} for name, (source, rel, caught) in cases.items(): wire = [(rel, detail, line) for kind, detail, line in findings(source, rel) if kind == "wire"] diff --git a/tests/test_sockdiag.py b/tests/test_sockdiag.py new file mode 100644 index 0000000..3e8a153 --- /dev/null +++ b/tests/test_sockdiag.py @@ -0,0 +1,207 @@ +"""``beantester/sockdiag.py``: the kernel's own list of TCP sockets on Linux. + +The fixtures are a real kernel's answers, byte for byte: Ubuntu 26.04.1, kernel +7.0.0-38, recorded 2026-10-26 by ``internal_tools`` probe ``2026-10-26-linux-reset`` +(``reset_probe.py goldenns``) inside a network namespace of its own. In it, three +connections: an IPv4 client (127.0.0.1:46112 to a listener on 127.0.0.1:46669), a +client opened as IPv6 for that IPv4 listener (``::ffff:127.0.0.1``, port 46124) and an +IPv6 client (::1:58668 to ::1:58121). The ports and addresses below are what Python's +own sockets said of themselves there (``getsockname``, ``getpeername``) - not what this +module reads. And the requests are the ones that kernel answered: the list, a destroy +naming no socket (ENOENT), and the destroy of the IPv4 client by its id (done). +""" +import socket +import sys + +import pytest + +from beantester import portmap, sockdiag, tcpreset +from fakes import check + +LIST4_REQUEST = bytes.fromhex( + "4800000014000103010000000000000002060000fffbffff00000000000000000000000000000000" + "000000000000000000000000000000000000000000000000ffffffffffffffff") +LIST4 = bytes.fromhex( + "7c000000140002000100000028c5000002010000b64db42c7f000001000000000000000000000000" + "7f000001000000000000000000000000000000000120000000000000000000000000000000000000" + "00000000b54a0300050008000000000008000f00000000000c001500938800000000000006001600" + "520000007c000000140002000100000028c5000002010000b420b64d7f0000010000000000000000" + "000000007f0000010000000000000000000000000000000002200000000000000000000000000000" + "0000000000000000b24a0300050008000000000008000f00000000000c0015009388000000000000" + "06001600520000007c000000140002000100000028c5000002010000b64db4207f00000100000000" + "00000000000000007f00000100000000000000000000000000000000032000000000000000000000" + "000000000000000000000000b34a0300050008000000000008000f00000000000c00150093880000" + "000000000600160052000000") +DONE = bytes.fromhex("14000000030002000100000028c5000000000000") +LIST6_REQUEST = bytes.fromhex( + "480000001400010301000000000000000a060000fffbffff00000000000000000000000000000000" + "000000000000000000000000000000000000000000000000ffffffffffffffff") +LIST6 = bytes.fromhex( + "7c000000140002000100000028c500000a010000b42cb64d00000000000000000000ffff7f000001" + "00000000000000000000ffff7f000001000000000520000000000000000000000000000000000000" + "00000000b44a0300050008000000000008000f00000000000c001500938800000000000006001600" + "120000007c000000140002000100000028c500000a010000e52ce309000000000000000000000000" + "00000001000000000000000000000000000000010000000006200000000000000000000000000000" + "0000000000000000b74a0300050008000000000008000f00000000000c0015009388000000000000" + "06001600120000007c000000140002000100000028c500000a010000e309e52c0000000000000000" + "00000000000000010000000000000000000000000000000100000000072000000000000000000000" + "000000000000000000000000b84a0300050008000000000008000f00000000000c00150093880000" + "000000000600160012000000") +NOBODY_REQUEST = bytes.fromhex( + "4800000015000500010000000000000002060000ffffffff00000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000") +NOBODY_ANSWER = bytes.fromhex( + "5c000000020000000100000028c50000feffffff4800000015000500010000000000000002060000" + "ffffffff000000000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000") +DESTROY_REQUEST = bytes.fromhex( + "4800000015000500010000000000000002060000ffffffffb420b64d7f0000010000000000000000" + "000000007f000001000000000000000000000000000000000220000000000000") +DESTROY_ANSWER = bytes.fromhex( + "24000000020000010100000028c500000000000048000000150005000100000000000000") + +# (local, local port, remote, remote port) of each socket, as Python's sockets said. +IN_V4 = {("127.0.0.1", 46112, "127.0.0.1", 46669), # the IPv4 client + ("127.0.0.1", 46669, "127.0.0.1", 46112), # the listener's side of it + ("127.0.0.1", 46669, "127.0.0.1", 46124)} # ... and of the IPv6-opened client +IN_V6 = {("127.0.0.1", 46124, "127.0.0.1", 46669), # the client opened as IPv6 + ("::1", 58668, "::1", 58121), + ("::1", 58121, "::1", 58668)} + + +class FakeNetlink: + """A netlink socket that answers with the datagrams it was given, one per read.""" + + def __init__(self, *datagrams, flags=0): + self.datagrams = list(datagrams) + self.flags = flags + self.sent = [] + self.closed = False + + def __enter__(self): + return self + + def __exit__(self, *_exc): + self.closed = True + + def sendto(self, data, address): + self.sent.append((data, address)) + + def recvmsg(self, _size): + return self.datagrams.pop(0), [], self.flags, (0, 0) + + +def kernel(*answers): + """``talk`` for ``tcp_sockets`` and ``tcpreset._Linux``: each request gets the next + answer (a tuple of datagrams), through the real ``exchange``. Keeps the requests.""" + sent = [] + queue = list(answers) + + def talk(message): + sent.append(message) + return sockdiag.exchange(message, opener=lambda: FakeNetlink(*queue.pop(0))) + talk.sent = sent + return talk + + +def ends(sockets): + return {(s.local_ip, s.local_port, s.remote_ip, s.remote_port) for s in sockets} + + +def test_the_kernels_own_list_reads_as_the_sockets_said_of_themselves(): + talk = kernel((LIST4, DONE), (LIST6, DONE)) + found = sockdiag.tcp_sockets(talk) + check("one list per family, each the request that kernel answered", + talk.sent == [LIST4_REQUEST, LIST6_REQUEST], + f"({[m.hex() for m in talk.sent]})") + v4 = [s for s in found if s.kernel_family == sockdiag.LINUX_AF_INET] + v6 = [s for s in found if s.kernel_family == sockdiag.LINUX_AF_INET6] + check("the IPv4 list", ends(v4) == IN_V4, f"({ends(v4)})") + check("the IPv6 list, the IPv6-opened client written as the IPv4 address it reached", + ends(v6) == IN_V6, f"({ends(v6)})") + check("every one TCP and open", {(s.proto, s.state) for s in found} == {("TCP", "ESTABLISHED")}) + check("the id is the kernel's, the mapped address in it", + [s.sockid[4:20].hex() for s in v6 if s.local_port == 46124] + == ["00000000000000000000ffff7f000001"]) + + +def test_a_socket_is_destroyed_by_the_id_the_list_gave_and_the_kernel_says_done(): + """The request ``tcpreset`` sends for the IPv4 client is byte for byte the one that + kernel destroyed it by - its cookie in it - and its answer reads as done.""" + found = sockdiag.tcp_sockets(kernel((LIST4, DONE), (LIST6, DONE))) + client = [s for s in found if s.local_port == 46112] + talk = kernel((DESTROY_ANSWER,)) + code = tcpreset._Linux(talk).destroy(client[0].kernel_family, client[0].sockid) + check("the request that kernel acknowledged", talk.sent == [DESTROY_REQUEST], + f"({talk.sent[0].hex()})") + check("and its answer: done", code == 0, f"({code})") + + +def test_the_destroy_that_names_no_socket_is_the_one_the_kernel_answered_enoent(): + talk = kernel((NOBODY_ANSWER,)) + check("ENOENT: this kernel destroys sockets", tcpreset._Linux(talk).can_destroy() is True) + check("the request: ports 0, addresses 0, cookie 0", talk.sent == [NOBODY_REQUEST], + f"({talk.sent[0].hex()})") + + +def test_an_answer_is_read_to_its_end_across_datagrams_and_the_socket_closed(): + nl = FakeNetlink(LIST4, DONE) + replies = sockdiag.exchange(b"request", opener=lambda: nl) + check("three sockets, then DONE", [kind for kind, _ in replies] + == [sockdiag.SOCK_DIAG_BY_FAMILY] * 3 + [sockdiag.NLMSG_DONE]) + check("sent to the kernel (port 0)", nl.sent == [(b"request", (0, 0))]) + check("and closed", nl.closed) + check("DONE ends in no error", sockdiag.error_of(replies) == 0) + + +def test_what_does_not_add_up_is_an_error_never_a_shorter_list(): + cut = FakeNetlink(LIST4, flags=sockdiag.MSG_TRUNC) + with pytest.raises(OSError, match="longer than the buffer"): + sockdiag.exchange(b"r", opener=lambda: cut) + check("closed after an error too", cut.closed) + with pytest.raises(OSError, match="longer than its datagram"): + sockdiag.exchange(b"r", opener=lambda: FakeNetlink(LIST4[:200])) + with pytest.raises(OSError, match="longer than its datagram"): # a length of 0 + sockdiag.exchange(b"r", opener=lambda: FakeNetlink(bytes(20))) + with pytest.raises(OSError, match="ends in type"): + sockdiag.error_of([(sockdiag.SOCK_DIAG_BY_FAMILY, LIST4[16:124])]) + with pytest.raises(OSError, match="in 40 bytes"): + sockdiag._socket(LIST4[16:56]) + + +def test_a_list_the_kernel_refuses_is_an_error_for_both_families(): + """An error in place of the list - in the IPv6 one as much as in the first - is + raised: a socket missing from a list that was not read says nothing.""" + refused = NOBODY_ANSWER[:16] + bytes.fromhex("f3ffffff") + NOBODY_ANSWER[20:] # -13 + for answers in (((refused,),), ((LIST4, DONE), (refused,))): + with pytest.raises(OSError, match="refused the list") as raised: + sockdiag.tcp_sockets(kernel(*answers)) + check("with the kernel's errno", raised.value.errno == 13, f"({raised.value.errno})") + + +def test_the_states_read_in_the_socket_tables_words(): + words = set(portmap.TCP_STATES.values()) + check("every state but the kernel's own NEW_SYN_RECV is a portmap word", + set(sockdiag.STATES.values()) - words == {"NEW_SYN_RECV"}, + f"({set(sockdiag.STATES.values()) - words})") + check("the states a reset sends a reset in are among them", tcpreset.RESETS <= words) + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="sock_diag is Linux's") +def test_a_loopback_connection_of_this_process_is_in_the_real_list(): # pragma: no cover - Linux + """The list needs no rights (measured as a user): a connection this test opens is in + it, with the ends its sockets give - on this runner's kernel, not a recording.""" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("127.0.0.1", 0)) + server.listen(1) + client = socket.create_connection(server.getsockname(), timeout=5) + peer, _ = server.accept() + try: + here = client.getsockname() + client.getpeername() + found = [s for s in sockdiag.tcp_sockets() + if (s.local_ip, s.local_port, s.remote_ip, s.remote_port) == here] + check("the client, open", [s.state for s in found] == ["ESTABLISHED"], f"({found})") + check("from the IPv4 list", found[0].kernel_family == sockdiag.LINUX_AF_INET) + finally: + for sock in (client, peer, server): + sock.close() diff --git a/tests/test_tcpreset.py b/tests/test_tcpreset.py index 895718d..61eebe0 100644 --- a/tests/test_tcpreset.py +++ b/tests/test_tcpreset.py @@ -7,6 +7,11 @@ the Linux runner too. The live test at the bottom resets a loopback connection it opened itself, on Windows with administrator rights: the measurement of 2026-10-14, kept as a test. + +Linux (``_Linux``): the fake is the kernel's side of netlink - the two lists as +``inet_diag_msg``, and the answer to SOCK_DESTROY. ``test_sockdiag.py`` holds a real +kernel's answers, which prove the layout these are built in; the real kernel resets +in ``tools/ci_linux_capture_smoke.py``, as root on the Linux runner. """ import ctypes import ipaddress @@ -16,7 +21,7 @@ import pytest -from beantester import crashlog, portmap, tcpreset, winenv +from beantester import crashlog, portmap, sockdiag, tcpreset, winenv from beantester.portmap import SocketRow from beantester.tcpreset import Outcome, Target from fakes import check @@ -62,12 +67,26 @@ def test_the_row_is_the_one_learn_describes(): def test_only_tcp_over_ipv4_is_offered(): + """Windows' rule, asked of Windows' system whichever runner this is on.""" cases = {("TCP", "198.51.100.7"): True, ("TCP", "127.0.0.1"): True, ("UDP", "198.51.100.7"): False, ("ICMP", "198.51.100.7"): False, ("TCP", "2001:db8::1"): False, ("TCP", "::ffff:198.51.100.7"): False, ("TCP", ""): False, ("TCP", None): False, ("IP", "198.51.100.7"): False} for (proto, ip), wanted in cases.items(): - check(f"{proto} {ip!r}", tcpreset.resettable(proto, ip) is wanted) + check(f"{proto} {ip!r}", tcpreset.resettable(proto, ip, tcpreset._System) is wanted) + + +def test_linux_offers_tcp_over_both_families_and_this_machine_asks_its_own(monkeypatch): + cases = {("TCP", "198.51.100.7"): True, ("TCP", "2001:db8::1"): True, + ("TCP", "fe80::1%2"): True, ("UDP", "2001:db8::1"): False, + ("TCP", ""): False, ("TCP", None): False} + for (proto, ip), wanted in cases.items(): + check(f"Linux: {proto} {ip!r}", tcpreset.resettable(proto, ip, tcpreset._Linux) is wanted) + for platform, system in (("linux", tcpreset._Linux), ("win32", tcpreset._System)): + monkeypatch.setattr(sys, "platform", platform) + check(f"{platform}: its own system", tcpreset._system_class() is system) + check(f"{platform}: and its rule for IPv6", tcpreset.resettable("TCP", "2001:db8::1") + is (system is tcpreset._Linux)) def test_the_one_matching_connection_is_reset_with_its_local_address(): @@ -137,6 +156,7 @@ def broken(): def no_library(): raise OSError("iphlpapi would not load") monkeypatch.setattr(tcpreset, "_iphlpapi", no_library) + monkeypatch.setattr(tcpreset, "_system_class", lambda: tcpreset._System) # any runner got = tcpreset.reset(TARGET) check("a binding that will not load is a FAILED too", got.kind == tcpreset.FAILED and "iphlpapi would not load" in got.detail, f"({got})") @@ -173,6 +193,149 @@ class SetTcpEntry: f"({got})") +# -- Linux ---------------------------------------------------------------------------- # +COOKIE = (41, 7) + + +def kernel_socket(state=1, local=LOCAL, local_port=51000, remote="198.51.100.7", + remote_port=443, family=sockdiag.LINUX_AF_INET, cookie=COOKIE): + """One ``inet_diag_msg``: family, state, timer, retransmits, the id, then the five + numbers nothing here reads.""" + af = socket.AF_INET if family == sockdiag.LINUX_AF_INET else socket.AF_INET6 + sid = (struct.pack("!HH", local_port, remote_port) + + socket.inet_pton(af, local).ljust(16, b"\0") + + socket.inet_pton(af, remote).ljust(16, b"\0") + struct.pack("=III", 0, *cookie)) + return bytes([family, state, 0, 0]) + sid + bytes(20) + + +class Kernel: + """``talk`` for ``tcpreset._Linux``: the IPv4 and the IPv6 list, then ``answer`` (an + errno) to every SOCK_DESTROY. ``refuse``: a family whose list is refused.""" + + def __init__(self, v4=(), v6=(), answer=0, refuse=None): + self.lists = {sockdiag.LINUX_AF_INET: v4, sockdiag.LINUX_AF_INET6: v6} + self.answer = answer + self.refuse = refuse + self.sent = [] + + def __call__(self, message): + self.sent.append(message) + kind, family = struct.unpack_from("=H", message, 4)[0], message[16] + if kind == tcpreset.SOCK_DESTROY: + return [(sockdiag.NLMSG_ERROR, struct.pack("=i", -self.answer) + message[:16])] + if family == self.refuse: + return [(sockdiag.NLMSG_ERROR, struct.pack("=i", -13) + message[:16])] + return ([(sockdiag.SOCK_DIAG_BY_FAMILY, payload) for payload in self.lists[family]] + + [(sockdiag.NLMSG_DONE, bytes(4))]) + + def destroys(self): + return [m for m in self.sent if struct.unpack_from("=H", m, 4)[0] == tcpreset.SOCK_DESTROY] + + +def test_linux_resets_the_one_socket_by_its_id_with_the_cookie_in_it(): + """🔴 Measured 2026-10-26: by its numbers alone the kernel takes the LISTENER on the + port when the connection has gone - it took a server down. The cookie names the + one socket the list showed.""" + near = (kernel_socket(local_port=51001, cookie=(1, 0)), + kernel_socket(remote="198.51.100.8", cookie=(2, 0)), + kernel_socket(remote_port=444, cookie=(3, 0))) + kernel = Kernel(v4=near + (kernel_socket(),)) + got = tcpreset.reset(TARGET, tcpreset._Linux(kernel)) + check("reset", got == Outcome(tcpreset.RESET), f"({got})") + sent = kernel.destroys() + check("one destroy", len(sent) == 1, f"({len(sent)})") + request = sent[0] + check("asked to answer (an acknowledgement, or why not)", + struct.unpack_from("=H", request, 6)[0] == sockdiag.NLM_F_REQUEST | sockdiag.NLM_F_ACK) + check("in the list the socket is in", request[16] == sockdiag.LINUX_AF_INET) + check("its ports and addresses", + request[24:28] == struct.pack("!HH", 51000, 443) + and request[28:32] == ipaddress.IPv4Address(LOCAL).packed + and request[44:48] == ipaddress.IPv4Address("198.51.100.7").packed) + check("and its cookie", request[-8:] == struct.pack("=II", *COOKIE), f"({request[-8:].hex()})") + + +def test_linux_finds_a_socket_opened_as_ipv6_for_an_ipv4_address(): + """Java's default: the socket is in the IPv6 list as ``::ffff:198.51.100.7``, its + row says 198.51.100.7 - and the destroy goes to the list it is in.""" + mapped = kernel_socket(local="::ffff:" + LOCAL, remote="::ffff:198.51.100.7", + family=sockdiag.LINUX_AF_INET6) + kernel = Kernel(v6=(mapped,)) + got = tcpreset.reset(TARGET, tcpreset._Linux(kernel)) + check("reset", got == Outcome(tcpreset.RESET), f"({got})") + check("through the IPv6 list", [m[16] for m in kernel.destroys()] == [sockdiag.LINUX_AF_INET6]) + + +def test_linux_resets_an_ipv6_connection(): + target = Target(51000, "2001:db8::7", 443) + kernel = Kernel(v6=(kernel_socket(local="2001:db8::2", remote="2001:db8::7", + family=sockdiag.LINUX_AF_INET6),)) + check("reset", tcpreset.reset(target, tcpreset._Linux(kernel)) == Outcome(tcpreset.RESET)) + + +def test_linux_resets_only_where_the_other_end_gets_a_reset(): + """Linux destroys a socket in any state; the far end gets a reset only in these + (``tcp_need_reset``) - a connection still opening was dropped and nobody told.""" + states = {1: "ESTABLISHED", 2: "SYN_SENT", 3: "SYN_RECEIVED", 4: "FIN_WAIT_1", + 5: "FIN_WAIT_2", 6: "TIME_WAIT", 8: "CLOSE_WAIT", 9: "LAST_ACK", + 11: "CLOSING", 12: "NEW_SYN_RECV"} + resets = {"ESTABLISHED", "SYN_RECEIVED", "FIN_WAIT_1", "FIN_WAIT_2", "CLOSE_WAIT"} + for number, name in states.items(): + kernel = Kernel(v4=(kernel_socket(state=number),)) + got = tcpreset.reset(TARGET, tcpreset._Linux(kernel)) + if name in resets: + check(f"{name}: reset", got == Outcome(tcpreset.RESET) and kernel.destroys(), f"({got})") + else: + check(f"{name}: refused, nothing sent", got == Outcome(tcpreset.REFUSED, name) + and not kernel.destroys(), f"({got})") + + +def test_linux_names_each_answer_of_the_kernel(): + cases = {0: Outcome(tcpreset.RESET), + 2: Outcome(tcpreset.GONE), # closed since the list + 1: Outcome(tcpreset.NEEDS_ADMIN, "ESTABLISHED", 1), + 95: Outcome(tcpreset.FAILED, "ESTABLISHED", 95, + detail="SOCK_DESTROY answered errno 95")} + for answer, wanted in cases.items(): + got = tcpreset.reset(TARGET, tcpreset._Linux(Kernel(v4=(kernel_socket(),), answer=answer))) + check(f"errno {answer}", got == wanted, f"({got})") + + +def test_linux_guesses_nothing_and_reads_no_missing_list_as_closed(): + two = (kernel_socket(), kernel_socket(local="192.0.2.11", cookie=(9, 0))) + kernel = Kernel(v4=two) + check("two local addresses: ambiguous", + tcpreset.reset(TARGET, tcpreset._Linux(kernel)) == Outcome(tcpreset.AMBIGUOUS)) + check("none in the lists: gone", tcpreset.reset(TARGET, tcpreset._Linux(Kernel())) + == Outcome(tcpreset.GONE)) + for family in (sockdiag.LINUX_AF_INET, sockdiag.LINUX_AF_INET6): + refused = Kernel(v4=(kernel_socket(),), refuse=family) + check(f"list {family} refused: unreadable, not gone", + tcpreset.reset(TARGET, tcpreset._Linux(refused)) == Outcome(tcpreset.UNREADABLE)) + check("and nothing destroyed", not refused.destroys() and not kernel.destroys()) + + +def test_linux_has_a_reset_when_its_kernel_destroys_sockets(monkeypatch): + """Asked with a destroy that names no socket: ENOENT where the kernel can (26.04), + EOPNOTSUPP where it was built without (WSL) - measured, as root and not.""" + monkeypatch.setattr(sys, "platform", "linux") + for answer, wanted in ((2, True), (95, False), (1, False)): + kernel = Kernel(answer=answer) + monkeypatch.setattr(sockdiag, "exchange", kernel) + check(f"errno {answer}", tcpreset.supported() is wanted) + check("the request names nobody: no ports, no addresses, cookie 0", + kernel.sent == [sockdiag.request(tcpreset.SOCK_DESTROY, + sockdiag.NLM_F_REQUEST | sockdiag.NLM_F_ACK, + sockdiag.LINUX_AF_INET, bytes(48))]) + + def no_netlink(_message): + raise PermissionError(1, "netlink refused to this process") + monkeypatch.setattr(sockdiag, "exchange", no_netlink) + check("no netlink at all: no reset", tcpreset.supported() is False) + monkeypatch.setattr(sys, "platform", "darwin") + check("another system: none", tcpreset.supported() is False) + + @pytest.mark.skipif(not sys.platform.startswith("win"), reason="SetTcpEntry is Windows'") def test_a_real_loopback_connection_is_reset_at_both_ends(): # pragma: no cover - Windows """The measurement, kept: an open connection this test made, reset through the diff --git a/tools/ci_linux_capture_smoke.py b/tools/ci_linux_capture_smoke.py index fe5c8f9..da21c43 100644 --- a/tools/ci_linux_capture_smoke.py +++ b/tools/ci_linux_capture_smoke.py @@ -25,6 +25,13 @@ raw socket, because a new namespace refuses the ping socket even to root (``ping_group_range = 1 0``), then through the ping socket once the namespace allows it - each carries both directions' latency, and is fast before and after. +* "Reset this connection", the Connections page's own ``tcpreset.reset``, on a TCP + connection from the namespace: with no session the program holding it hears + ECONNABORTED and the far end a reset; during ``--loss 100`` toward the far end the + program still hears it and the far end hears nothing - the reset is a packet like any + other. And a connection that closed after the socket list was read: its destroy ends + as "gone", and the listener on its port lives on (by its numbers alone, without the + cookie, the kernel took the listener - measured 2026-10-26). What bounds the damage (convention 6) ------------------------------------- @@ -32,6 +39,7 @@ (``bntci``, joined to the root namespace by a veth pair) and deletes again, also when a phase fails. The sessions run INSIDE it, with ``--dst-ip``, ``--dst-port`` and ``--duration``, so the machine's own interfaces are never handed to the tool. +The resets act only on connections this script opened, from inside the namespace. Not Linux, not root, or no eBPFDivert -> SKIP and exit 0, unless ``--require`` is given, which CI passes: there a check that cannot run must fail, not pass quietly. @@ -83,6 +91,53 @@ "print(json.dumps({'kind': icmpsock.kind(), 'rtts': out}))\n") PING_ROUNDS = 6 +RESET_PORT = 47103 +ECONNABORTED = 103 # what Linux tells the program whose socket a reset destroyed + +# Run INSIDE the namespace, from the checkout: one TCP connection to the far end, held +# open until a line arrives on stdin, then the Connections page's own reset of it and +# what the program holding it hears next. +RESET_CLIENT = ( + "import json, socket, sys\n" + "from beantester import tcpreset\n" + "host, port = sys.argv[1], int(sys.argv[2])\n" + "s = socket.create_connection((host, port), timeout=5)\n" + "print('ready', flush=True)\n" + "sys.stdin.readline()\n" + "got = tcpreset.reset(tcpreset.Target(s.getsockname()[1], host, port))\n" + "try:\n" + " s.recv(1)\n" + " heard = None\n" + "except OSError as exc:\n" + " heard = exc.errno\n" + "print(json.dumps({'outcome': got.kind, 'client': heard}), flush=True)\n") + +# Run INSIDE the namespace: a connection to a listener there, ended by a reset from its +# client, then destroyed by the socket the list showed before - what a click meets when +# the connection closes between reading the list and the destroy. +STALE_CLIENT = ( + "import json, socket, struct, time\n" + "from beantester import tcpreset\n" + "srv = socket.socket()\n" + "srv.bind(('127.0.0.1', 0))\n" + "srv.listen(4)\n" + "port = srv.getsockname()[1]\n" + "c = socket.create_connection(('127.0.0.1', port), timeout=5)\n" + "a, _ = srv.accept()\n" + "system = tcpreset._Linux()\n" + "rows, _ = system.rows()\n" + "served = [r for r in rows if r.local_port == port and r.remote_port == c.getsockname()[1]]\n" + "c.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0))\n" + "c.close()\n" + "time.sleep(0.2)\n" + "stale = system.end(served[0], None).kind if len(served) == 1 else None\n" + "try:\n" + " socket.create_connection(('127.0.0.1', port), timeout=2).close()\n" + " listener = 'alive'\n" + "except OSError as exc:\n" + " listener = type(exc).__name__\n" + "print(json.dumps({'found': len(served), 'stale': stale, 'listener': listener}))\n") + # -- the judges: pure, so a test can prove they fail ------------------------------ # def _answered(rtts): @@ -151,6 +206,28 @@ def judge_ping(before, raw, dgram, after, latency_ms=LATENCY_MS): return failures +def judge_reset(plain, impaired, stale): + """Failures (empty = pass) for the Reset phase. ``plain`` is a reset with no session, + ``impaired`` one during ``--loss 100`` toward the far end, each ``{"outcome", + "client", "far"}`` - ``far`` being what the far end heard, "reset" or "nothing". + ``stale``: the destroy of a connection that closed after the list was read.""" + failures = [] + for name, got, far in (("plain reset", plain, "reset"), ("reset under loss", impaired, "nothing")): + if got.get("outcome") != "reset": + failures.append(f"{name}: ended {got.get('outcome')!r}, not 'reset'") + if got.get("client") != ECONNABORTED: + failures.append(f"{name}: the program heard errno {got.get('client')}, not ECONNABORTED") + if got.get("far") != far: + failures.append(f"{name}: the far end heard {got.get('far')!r}, expected {far!r}") + if stale.get("found") != 1: + failures.append(f"stale: {stale.get('found')} sockets listed for the connection, not 1") + if stale.get("stale") != "gone": + failures.append(f"stale: a closed connection's destroy ended {stale.get('stale')!r}, not 'gone'") + if stale.get("listener") != "alive": + failures.append(f"stale: the listener on its port: {stale.get('listener')} - the destroy took it") + return failures + + def skip_reason(platform, euid, library_loaded): """Why this machine cannot run the check, or None.""" if not platform.startswith("linux"): @@ -324,6 +401,90 @@ def during(): return failures +def start_tcp_far_end(): + """A TCP listener in the root namespace, in THIS process: the far end a reset reaches.""" + far = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + far.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + far.bind((HOST, RESET_PORT)) + far.listen(4) + far.settimeout(10) + return far + + +def heard(conn, wait): + """What the far end of a connection hears within ``wait`` s - "reset" or "nothing" + ("closed" or "data" no reset should bring) - and the far end's socket closed.""" + conn.settimeout(wait) + try: + data = conn.recv(16) + except ConnectionResetError: + return "reset" + except TimeoutError: + return "nothing" + finally: + conn.close() + return "data" if data else "closed" + + +def held_connection(far, clients, ends): + """The reset client, connected from the namespace and waiting; and the far end's + socket of it, or None when it never connected. The client goes on ``clients`` and the + far end's socket on ``ends`` before anything can fail, so the caller stops and + closes them on every path.""" + p = subprocess.Popen(in_namespace(sys.executable, "-c", RESET_CLIENT, HOST, str(RESET_PORT)), + cwd=ROOT, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, text=True) + clients.append(p) + if p.stdout.readline().strip() != "ready": + return p, None + try: + conn = far.accept()[0] + except OSError: # the far end's 10 s wait ran out + return p, None + ends.append(conn) + return p, conn + + +def fire(client, conn, wait): + """Reset the held connection: the client's report, and what the far end heard.""" + client.stdin.write("go\n") + client.stdin.flush() + got = json.loads(client.stdout.readline() or "{}") + got["far"] = heard(conn, wait) + return got + + +def _reset_phase(): + far = start_tcp_far_end() + clients, ends = [], [] + try: + client, conn = held_connection(far, clients, ends) + plain = fire(client, conn, 2.0) if conn else {"outcome": "never connected"} + # Connected BEFORE the session: --loss 100 would keep a new connection out. The + # far end listens 1.5 s of a 6 s session. + client, conn = held_connection(far, clients, ends) + if conn is None: + return ["reset under loss: the client never connected"] + result, lines, code = run_session( + ["--dst-ip", HOST, "--dst-port", str(RESET_PORT), "--loss", "100", "--duration", "6"], + lambda: fire(client, conn, 1.5)) + failures = _session_failures("reset", result, lines, code) + r = subprocess.run(in_namespace(sys.executable, "-c", STALE_CLIENT), cwd=ROOT, + capture_output=True, text=True, timeout=30) + stale = json.loads(r.stdout.strip() or "{}") + failures += judge_reset(plain, result or {}, stale) + print("reset:", json.dumps({"plain": plain, "under loss": result, "stale": stale})) + return failures + finally: + for client in clients: + if client.poll() is None: + client.kill() + client.wait(10) + for conn in ends: # closed already by heard(), unless a step failed first + conn.close() + far.close() + + def main(argv=None, environment=None): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--require", action="store_true", @@ -348,6 +509,7 @@ def main(argv=None, environment=None): failures += _latency_phase() failures += _loss_phase() failures += _ping_phase() + failures += _reset_phase() except Exception as exc: # a phase that could not run is a failure failures.append(f"{type(exc).__name__}: {exc}") finally: