Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions beantester/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 18 additions & 5 deletions beantester/gui/pages/conns.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
driver's own filter has been narrowed to the destination.
"""

import sys
import threading
import time
import tkinter as tk
Expand Down Expand Up @@ -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"))

Expand Down Expand Up @@ -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))

Expand Down
165 changes: 165 additions & 0 deletions beantester/sockdiag.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading