diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e42c07a..4936b04f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,14 +19,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol capture, and a refused START says what to do. The Statistics page and its CSV count 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 Show TCP health. + starting a browser as root. On the Tools tab, TCP health says it is 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 - **Clean up the driver** says why there is nothing to clean up. **Add network adapters** and - **Add proxy settings** are greyed out and say why, because the Adapters tool works on - Windows only for now. + **Clean up the driver** says why there is nothing to clean up. - **Linux, experimental: Ping works.** It sends through the system's ping socket, which needs no sudo where the system lets your group send pings (`net.ipv4.ping_group_range`), and under @@ -43,6 +41,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol 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. +- **Linux, experimental: Adapters and its Proxy row work, without sudo.** The adapters, their + addresses, gateways and the route to the internet are what `ip addr` and `ip route` show, + and each DNS server is listed at the adapter its questions leave through. The Proxy row + shows the desktop's proxy settings (GNOME) and the proxy variables in `/etc/environment` + and the `environment.d` folders, also under sudo as yours. **Add network adapters** and + **Add proxy settings** in Diagnostics work too. + - **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 197910e3..a4cf7e04 100644 --- a/README.md +++ b/README.md @@ -212,9 +212,8 @@ exit code 1, as on Windows without administrator rights. Where Linux differs fro through a raw socket, which needs the capability CAP_NET_RAW: root has it unless the system takes it away, as some containers do. A ping Linux refuses shows its own word for it: `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, and the Diagnostics boxes that add the adapters or the proxy to the report. The + A zone of an IPv6 address can be the interface's name (`fe80::1%eth0`). TCP health says that + it works on Windows only for now, and so do the row menu entries that open it. 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 @@ -222,6 +221,21 @@ exit code 1, as on Windows without administrator rights. Where Linux differs fro `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. +- **Adapters** on the Tools tab works on Linux, without `sudo`. The adapters, their addresses, + the gateways and the route to the internet come from the kernel's own tables, as `ip addr` + and `ip route` show them, and the MAC address is written the way `ip` writes it. An adapter + whose driver reports no state, such as the loopback or a WireGuard tunnel, is Up when it has + a link (`ip` shows it as UNKNOWN). Each DNS server is listed at the adapter its questions + leave through: when systemd-resolved answers for this computer, `127.0.0.53` is at the + loopback and the servers it asks are at their own adapters. DHCP is Yes when an IPv4 + address has a lease or the default route came from DHCP. The DHCP server and the DNS suffix + are not shown: only NetworkManager's own files hold them. +- The **Proxy** row of Adapters shows the desktop's proxy settings (GNOME) and the proxy + variables in `/etc/environment`, the `environment.d` folders and your + `~/.config/environment.d`. Variables set in a shell's startup files, such as `~/.bashrc`, are + not read. The desktop's settings are read with `gsettings`, as you, also under `sudo`: this + is the one program the tool starts, and it sends nothing. There is no proxy for services as + on Windows (WinHTTP). ## The window @@ -353,9 +367,9 @@ monitor is gone it returns to the centre of the current screen. field"** puts the expression into that field, replacing what is there. What you type stays until you close the program, even if you switch the language. - **Adapters** - every network adapter on this computer and the settings a test depends - on, the kind `ipconfig /all` prints (Windows only). The adapter your internet traffic - leaves through is at the top, - then the ones that are up. Choose one to see its addresses with their prefixes, the + on, the kind `ipconfig /all` prints (on Linux, `ip addr` and `ip route`). The adapter + your internet traffic leaves through is at the top, then the ones that are up. Choose + one to see its addresses with their prefixes, the gateway, the DNS servers, DHCP, the MAC address, the MTU and the link speed. A note warns when another device uses the same address. The list is read when you open the tab and when you press **Refresh**. Right-click a gateway or a DNS server to put its address @@ -1603,9 +1617,11 @@ beantester/ the implementation package validators.py number and range validation (shared by GUI, CLI and config file) portmap.py socket table: local port -> PID (iphlpapi/ctypes; psutil fallback), and every socket with its state for the Tools tab - adapters.py network adapters and the route out (iphlpapi/ctypes), for the Tools tab + adapters.py network adapters and the route out (iphlpapi/ctypes; on Linux the + kernel's tables, sysfs and the resolver's files), for the Tools tab proxy.py the proxy this account and this machine use (winhttp/ctypes and the - registry), read only, for the Tools tab + registry; on Linux gsettings and the environment files), 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; on Linux the kernel's @@ -1614,6 +1630,10 @@ beantester/ the implementation package 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 + rtnetlink.py the kernel's tables of adapters, addresses and routes on Linux, and a + route lookup (what ip route get asks), read only, for the adapters + netlink.py the message socket to the Linux kernel under sockdiag and rtnetlink: + one request out, its answer read to its end 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/adapters.py b/beantester/adapters.py index b471dfa9..bced4ab5 100644 --- a/beantester/adapters.py +++ b/beantester/adapters.py @@ -10,6 +10,12 @@ adapter list and the routing table - and neither is in the wire-function list of ``tests/test_no_telemetry.py`` (the ping and ARP exports of the same DLL are). +On Linux (``_Linux``) the same rows come from the kernel's own tables - what ``ip +link``, ``ip addr`` and ``ip route`` read, through ``rtnetlink`` - with the device's +driver, Wi-Fi and link speed from sysfs, and the DNS servers from the resolver's own +files. Every field matched ``ip -j`` on a real kernel (``rtnetlink``'s docstring has +the measurement); what Linux words differently is written at ``_Linux``. + Why the structures are written with fixed-width integers ----------------------------------------------------- ``wintypes.DWORD`` is 8 bytes on Linux (measured on CPython 3.14, see @@ -32,11 +38,12 @@ """ import ctypes import ipaddress +import os import struct import sys from typing import NamedTuple -from . import crashlog +from . import crashlog, paths, rtnetlink from .utils import INTERNET_PROBES # where "the internet" is, shared with host_identity # Address families as Windows numbers them (AF_INET6 is 23 there, not POSIX 10). @@ -159,6 +166,9 @@ class AdapterRow(NamedTuple): dns_suffix: str ipv4_metric: int | None ipv6_metric: int | None + # The DNS servers above that are this computer's own resolver, which asks the + # servers on the other adapters (systemd-resolved on Linux). Windows lists none. + stub_dns: tuple = () class Reading(NamedTuple): @@ -168,7 +178,8 @@ class Reading(NamedTuple): class AdaptersUnavailable(Exception): """The adapter list could not be read. ``reason``: "not_ready" for the one - documented failure a person can wait out, "" for any other.""" + documented failure a person can wait out, "changing" for a Linux list that kept + changing while it was read, "" for any other.""" def __init__(self, reason, detail=""): super().__init__(detail or reason) @@ -378,9 +389,269 @@ def best_interface(self, version): return int(index.value) if rc == 0 else 0 +# -- Linux: the kernel's tables, in the rows above -------------------------------- # +# The link layer (ARPHRD_*, include/uapi/linux/if_arp.h) as the IANA ifType Windows +# reports, so one table of words serves both systems. +ARPHRD_ETHER, ARPHRD_PPP, ARPHRD_LOOPBACK, ARPHRD_NONE = 1, 512, 772, 65534 +ARPHRD_TUNNELS = frozenset({768, 769, 776, 778, 823}) # ipip, ip6tnl, sit, gre, ip6gre +IF_OTHER, IF_ETHERNET, IF_PPP, IF_LOOPBACK, IF_VIRTUAL, IF_WIFI, IF_TUNNEL = ( + 1, 6, 23, 24, 53, 71, 131) +# IF_OPER_* are RFC 2863's states numbered the kernel's way; OperStatus numbers them +# the RFC's way. +IF_OPER_UNKNOWN = 0 +OPER_STATUS = {0: 4, 1: 6, 2: 2, 3: 7, 4: 3, 5: 5, 6: 1} +STATUS_UP, STATUS_UNKNOWN = 1, 4 +IFF_UP, IFF_LOWER_UP = 0x1, 0x10000 +# IFA_F_* (include/uapi/linux/if_addr.h). 0x01 is TEMPORARY for IPv6 and SECONDARY +# for IPv4 - one bit, two meanings (measured). +IFA_F_TEMPORARY, IFA_F_OPTIMISTIC, IFA_F_DADFAILED = 0x01, 0x04, 0x08 +IFA_F_DEPRECATED, IFA_F_TENTATIVE, IFA_F_PERMANENT = 0x20, 0x40, 0x80 +IFA_F_MANAGETEMPADDR = 0x100 +IFA_PROTO_KERNEL_RA = 2 +RTPROT_DHCP = 16 +RT_TABLE_LOCAL = 255 +RTN_UNICAST = 1 +# NL_PREFIX_ORIGIN and NL_DAD_STATE, as AddressRow carries them, and one origin of +# the program's own: an address with a lifetime the kernel cannot say the source of. +ORIGIN_MANUAL, ORIGIN_WELL_KNOWN, ORIGIN_DHCP, ORIGIN_ROUTER = 1, 2, 3, 4 +LEASE = -1 +SUFFIX_RANDOM = 5 +DAD_TENTATIVE, DAD_DUPLICATE, DAD_DEPRECATED, DAD_PREFERRED = 1, 2, 3, 4 +_SCOPED = (ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("169.254.0.0/16"), + ipaddress.ip_network("::1/128"), ipaddress.ip_network("fe80::/10")) + +# The resolver's own files. glibc asks the first three servers (MAXNS) and nothing +# after them; systemd-resolved, when /etc/resolv.conf sends programs to its stub, +# asks the servers in its own file. +RESOLV_CONF = "etc/resolv.conf" +RESOLVED_CONF = "run/systemd/resolve/resolv.conf" +RESOLVER_STUBS = frozenset({"127.0.0.53", "127.0.0.54"}) +MAX_NAMESERVERS = 3 + + +def _linux_status(link): + """OperStatus. The kernel says UNKNOWN for a driver that keeps no state - the + loopback, a dummy, WireGuard (measured) - and its documentation + (networking/operstates.rst) says such an adapter "must be considered for user + data": up, when it is switched on and has its link.""" + if (link.operstate == IF_OPER_UNKNOWN + and link.flags & IFF_UP and link.flags & IFF_LOWER_UP): + return STATUS_UP + return OPER_STATUS.get(link.operstate, STATUS_UNKNOWN) + + +def _linux_kind(link, wireless): + if link.arphrd == ARPHRD_LOOPBACK: + return IF_LOOPBACK + if link.arphrd == ARPHRD_ETHER: + if wireless: + return IF_WIFI + return IF_VIRTUAL if link.kind else IF_ETHERNET + if link.arphrd == ARPHRD_NONE or link.arphrd in ARPHRD_TUNNELS: + return IF_TUNNEL # tun, WireGuard, the IP-in-IP family + return IF_PPP if link.arphrd == ARPHRD_PPP else IF_OTHER + + +def _linux_mac(raw): + """As ``ip link`` writes it; "" for none, and for the loopback's six zeros.""" + return ":".join(f"{b:02x}" for b in raw) if any(raw) else "" + + +def _linux_origin(address, ip): + if any(ip in network for network in _SCOPED if network.version == ip.version): + return ORIGIN_WELL_KNOWN + if address.flags & IFA_F_PERMANENT: + return ORIGIN_MANUAL + if (address.proto == IFA_PROTO_KERNEL_RA + or (ip.version == 6 and address.flags & (IFA_F_TEMPORARY | IFA_F_MANAGETEMPADDR))): + return ORIGIN_ROUTER + # A lifetime and no word from the kernel on who gave it: on IPv4 only DHCP does + # that; on IPv6 DHCPv6 and a router's advertisement both do, and NetworkManager + # makes the second kind itself, so the kernel cannot tell them apart. + return ORIGIN_DHCP if ip.version == 4 else LEASE + + +def _linux_dad(address): + if address.flags & IFA_F_DADFAILED: # it comes with TENTATIVE (measured) + return DAD_DUPLICATE + if address.flags & (IFA_F_TENTATIVE | IFA_F_OPTIMISTIC): + return DAD_TENTATIVE + if address.flags & IFA_F_DEPRECATED or address.preferred == 0: + return DAD_DEPRECATED + return DAD_PREFERRED + + +def _linux_address(address): + ip = ipaddress.ip_address(address.ip) + suffix = SUFFIX_RANDOM if ip.version == 6 and address.flags & IFA_F_TEMPORARY else 0 + return AddressRow(address.ip, ip.version, address.prefix, _linux_origin(address, ip), + suffix, _linux_dad(address)) + + +def _nameservers(text, limit): + """The ``nameserver`` addresses of a resolv.conf, first ``limit`` of them.""" + found = [] + for line in (text or "").splitlines(): + words = line.split() + if len(words) >= 2 and words[0] == "nameserver": + found.append(words[1]) + return found[:limit] + + +class _Device(NamedTuple): + driver: str + wireless: bool + speed_bps: int | None + + +class _Linux: + """The kernel's tables as ``AdapterRow``s. ``talk`` (one rtnetlink request) and + ``root`` (where /sys, /etc and /run are) are injectable, so the rows are made in + tests anywhere from a real kernel's bytes. + + Where Linux says it differently: the key is ``if:`` and the adapter's name - the + name stays when the adapter is made again, its index does not, and a name cannot + hold ":" (the kernel refuses it), so no key is ever the proxy row's. One index + serves IPv4 and IPv6. The MAC is written as ``ip link`` writes it. DHCP is "yes" + when an IPv4 address has a lease or a default route came from DHCP; who served + it, and the DNS suffix, sit only in files NetworkManager keeps for itself, so + they are not shown. The metric is the default route's. Each DNS server is shown + at the adapter the kernel would send to it through. + """ + + def __init__(self, talk=None, root="/"): + self.talk = talk or rtnetlink.exchange + self.root = root + + def adapters(self): + try: + tables = rtnetlink.read(self.talk) + except rtnetlink.Changing as exc: + raise AdaptersUnavailable("changing", str(exc)) from exc + except OSError as exc: + raise AdaptersUnavailable("", f"rtnetlink: {exc}") from exc + links = tables.links[:MAX_ADAPTERS] + try: + dns, stubs = self._dns(links) + except OSError as exc: # a resolver file it may not read costs the DNS servers only + crashlog.note(exc, "adapters.dns") + dns, stubs = {}, frozenset() + mine = _by_link(tables) + return tuple(self._row(link, mine.get(link.ifindex, ([], [])), dns.get(link.ifindex, ()), + stubs) for link in links) + + def best_interface(self, version): + return rtnetlink.route_to(INTERNET_PROBES[version], self.talk) + + # -- one adapter -------------------------------------------------------- # + def _row(self, link, mine, dns, stubs): + """``mine``: the link's addresses and default routes (``_by_link``).""" + addresses, defaults = mine + name = link.name.decode("utf-8", errors="replace") + status = _linux_status(link) + device = self._device(link, status == STATUS_UP) + rows = tuple(_linux_address(a) for a in addresses[:MAX_ENTRIES]) + gateways = tuple(dict.fromkeys(r.gateway for r in defaults if r.gateway))[:MAX_ENTRIES] + dhcp = (any(a.version == 4 and a.origin == ORIGIN_DHCP for a in rows) + or any(r.protocol == RTPROT_DHCP for r in defaults)) + speed = device.speed_bps + return AdapterRow( + "if:" + link.name.decode("utf-8", errors="backslashreplace"), link.ifindex, + link.ifindex if any(a.version == 6 for a in rows) else 0, name, + device.driver or link.kind, _linux_kind(link, device.wireless), status, + _linux_mac(link.address), link.mtu, speed, speed, dhcp, "", rows, gateways, + tuple(dns[:MAX_ENTRIES]), "", _metric(defaults, rtnetlink.LINUX_AF_INET), + _metric(defaults, rtnetlink.LINUX_AF_INET6), + tuple(server for server in dns if server in stubs)) + + def _device(self, link, up): + """The driver, Wi-Fi and speed sysfs gives for ``link``; nothing of it for an + adapter gone since the list was read. The speed only for one that is up: down, + the kernel answers EINVAL, and some drivers ask the hardware first.""" + try: + # sysfs names the adapter by its bytes (a Windows runner's path cannot + # hold them all: a ValueError there, never on Linux). + base = os.path.join(os.fsencode(self.root), b"sys", b"class", b"net", link.name) + except ValueError: + return _Device("", False, None) + try: + driver = os.path.basename(os.readlink(os.path.join(base, b"device", b"driver"))) + except OSError: + driver = b"" + wireless = (os.path.isdir(os.path.join(base, b"wireless")) + or os.path.exists(os.path.join(base, b"phy80211"))) + return _Device(driver.decode("utf-8", errors="replace"), wireless, + _speed(os.path.join(base, b"speed")) if up else None) + + # -- the DNS servers ---------------------------------------------------- # + def _dns(self, links): + """``{index: [server, ...]}`` and the servers that are a local stub.""" + listed = _nameservers(paths.read_small_file(self._path(RESOLV_CONF)), MAX_NAMESERVERS) + stubs = RESOLVER_STUBS & set(listed) + servers = list(listed) + if stubs: + servers += _nameservers(paths.read_small_file(self._path(RESOLVED_CONF)), + MAX_ENTRIES) + by_name = {link.name.decode("utf-8", errors="replace"): link.ifindex for link in links} + out: dict = {} + for server in dict.fromkeys(servers): + index = self._dns_index(server, by_name) + if index: + out.setdefault(index, []).append(server.split("%")[0]) + return out, stubs + + def _dns_index(self, server, by_name): + """The adapter a query to ``server`` leaves through: the one its zone names, + else the kernel's route to it. 0 when there is none.""" + ip, _, zone = server.partition("%") + if zone: + return by_name.get(zone, 0) + try: + return rtnetlink.route_to(ip, self.talk) + except (OSError, ValueError) as exc: + crashlog.note(exc, "adapters.dns_route") + return 0 + + def _path(self, relative): + return os.path.join(self.root, relative) + + +def _by_link(tables): + """``{ifindex: ([address, ...], [default route, ...])}`` - each table walked once, not + once per adapter: a host with hundreds of container adapters has thousands of rows.""" + out: dict = {} + for address in tables.addresses: + if address.ip: + out.setdefault(address.ifindex, ([], []))[0].append(address) + for route in tables.routes: + if route.dst_len == 0 and route.kind == RTN_UNICAST and route.table != RT_TABLE_LOCAL: + out.setdefault(route.oif, ([], []))[1].append(route) + return out + + +def _speed(path): + """Bits per second from sysfs' ``speed`` (Mbit/s); None where it says none: no + file, EINVAL (no link speed to give) or -1 (the driver does not know).""" + try: + with open(path, "rb") as f: + mbps = int(f.read().strip() or b"0") + except (OSError, ValueError): + return None + return mbps * 10 ** 6 if mbps > 0 else None + + +def _metric(defaults, family): + metrics = [r.priority for r in defaults if r.family == family and r.priority is not None] + return min(metrics) if metrics else None + + def supported(): - """The adapter list is read on Windows only (the owner's decision, 2026-09-24).""" - return sys.platform.startswith("win") + """The adapter list is read on Windows (since 2026-09-24) and on Linux (2026-10-27).""" + return sys.platform.startswith(("win", "linux")) + + +def _system_class(): + return _Linux if sys.platform.startswith("linux") else _Native def read(native=None): @@ -389,7 +660,7 @@ def read(native=None): A route lookup that fails costs the "internet" mark and nothing else: the list is still true without it. """ - native = native or _Native() + native = native or _system_class()() rows = native.adapters() internet = {} for version in (4, 6): diff --git a/beantester/gui/toolbox/adapters.py b/beantester/gui/toolbox/adapters.py index e4f8cc46..da1ce789 100644 --- a/beantester/gui/toolbox/adapters.py +++ b/beantester/gui/toolbox/adapters.py @@ -13,16 +13,20 @@ uses a proxy connects to IT, so its address is offered to the Control page like a gateway's. +The same panel on Windows and Linux: the rows come from each system's own reader +(``adapters._Native`` / ``adapters._Linux``, ``proxy`` likewise) in the same shapes, +and only the "?" sheet and the name column's tip are each system's own. + A snapshot, read when the tab is first looked at and then when asked - the pattern of the socket table: a list that re-read itself on every tick would move -under the pointer. Windows only (the owner's decision, 2026-09-24): elsewhere the -tab says so in one line. +under the pointer. The chosen adapter is remembered by the name the system keeps for it (``AdapterName``), not by its index: Learn says an index may change when an adapter is disabled and enabled again, and the details would then belong to another one. """ +import sys import time import tkinter as tk from tkinter import ttk @@ -45,6 +49,10 @@ "kind": "tools.adapters.col.kind", "ipv4": "tools.adapters.col.ipv4", "internet": "tools.adapters.col.internet"} TIPS = {column: f"tips.tools_adapters_col_{column}" for column in COLUMNS} +# What Linux says in its own words: the whole "?" sheet (nearly every paragraph of +# Windows' names a Windows place), and where an adapter's name comes from. +HELP, HELP_LINUX = "tools.adapters.help_body", "tools.adapters.help_linux" +TIPS_LINUX = {**TIPS, "name": "tips.tools_adapters_col_name_linux"} MIN_CHARS = {"name": CHARS["adapter"], "status": CHARS["if_status"], "kind": CHARS["if_kind"], "ipv4": CHARS["address"], "internet": CHARS["internet"]} @@ -56,6 +64,20 @@ READ = "read" + +def _is_linux(platform=None): + return (platform or sys.platform).startswith("linux") + + +def help_key(platform=None): + """The "?" sheet of this system.""" + return HELP_LINUX if _is_linux(platform) else HELP + + +def tips(platform=None): + """The list's column tips on this system.""" + return TIPS_LINUX if _is_linux(platform) else TIPS + # The list's menu: "Copy all of this adapter's details" (the owner's request, 2026-10-19). COPY_ADAPTER = 0 @@ -111,8 +133,7 @@ def __init__(self, app, parent): "tips.tools_adapters_refresh", self.read) self.copy_btn = self._button(bar, "tools.adapters.copy_report", "tips.tools_adapters_copy", self.copy_report) - dialogs.help_button(bar, app.root, "tools.adapters.help_title", - "tools.adapters.help_body", + dialogs.help_button(bar, app.root, "tools.adapters.help_title", help_key(), "tips.tools_adapters_help").pack(side="right") # How old the rows on screen are, after a read that failed. In the bar, where # the socket table keeps its row count, and not on a line of its own: the @@ -130,7 +151,7 @@ def __init__(self, app, parent): top.pack(fill="x", padx=space("page"), pady=(space("tight"), 0)) self.table = SortableTree( top, COLUMNS, sort=self._sort("sort"), on_sort=self._on_sort, - height=ROWS["adapters"], min_chars=MIN_CHARS, tips=TIPS, tags=ADAPTER_COLORS, + height=ROWS["adapters"], min_chars=MIN_CHARS, tips=tips(), tags=ADAPTER_COLORS, selectmode="browse", empty_text="tools.common.working") self.table.bind_select(self._on_select) bottom = ttk.Frame(self.frame) diff --git a/beantester/gui/toolbox/diagnostics.py b/beantester/gui/toolbox/diagnostics.py index b6598c74..382d3815 100644 --- a/beantester/gui/toolbox/diagnostics.py +++ b/beantester/gui/toolbox/diagnostics.py @@ -10,6 +10,7 @@ window. The check runs by itself the first time the tab is shown; after that only when asked, and the status line says when the rows on screen were taken. """ +import sys import tkinter as tk from tkinter import ttk from typing import NamedTuple @@ -53,6 +54,17 @@ class Box(NamedTuple): "tools.diagnostics.help_proxy", "proxy"), ) +# Where a box's tip names Windows' places, the words Linux has for its own. +BOX_TIPS_LINUX = {"proxy": "tips.tools_diagnostics_proxy_linux"} + + +def box_tip(box, platform=None): + """``box``'s tip on this system.""" + if (platform or sys.platform).startswith("linux"): + return BOX_TIPS_LINUX.get(box.section, box.tip) + return box.tip + + # What a box says when this system cannot read its section. Greyed out and still # there, as a row menu greys out what the system lacks (capabilities.py): the # owner's decision for Linux is that a missing tool stays visible and says so. @@ -123,7 +135,7 @@ def __init__(self, app, parent): box = ttk.Checkbutton(boxes, text=T(spec.text), variable=ticked, command=self._ticked) box.pack(side="left", padx=(0, space("inline"))) if offered: - add_tooltip(box, spec.tip) + add_tooltip(box, box_tip(spec)) self.include[spec.section] = ticked else: box.state(["disabled"]) diff --git a/beantester/netlink.py b/beantester/netlink.py new file mode 100644 index 00000000..80d7fc21 --- /dev/null +++ b/beantester/netlink.py @@ -0,0 +1,126 @@ +"""Netlink, the kernel's own message socket on Linux: one request out, its answer back. + +The transport under the two modules that ask the kernel's tables: ``sockdiag`` (the +TCP socket list, and the destroy behind "Reset this connection") and ``rtnetlink`` +(the adapters, their addresses and the routes). Each says what to ask; this sends it +and reads the answer to its end, so the rules for a datagram that does not add up live +in one place. + +The layouts are the kernel's (``include/uapi/linux/netlink.h``), which never change: a +16-byte header (length, type, flags, sequence, port) in the machine's byte order, +messages padded to 4 bytes, and attributes inside them as length, type and value. The +numbers are written out, not taken from Python: Python on Windows has no +``AF_NETLINK``, and the type checker reads this file there. + +A request is answered in one of two ways: a list (``NLM_F_DUMP``) ends in NLMSG_DONE, +and anything else ends in NLMSG_ERROR - an acknowledgement is an error of 0, and it +only comes when the request asks for one (``NLM_F_ACK``). A request that asks for +neither is answered with its reply and nothing after it, so the read below would wait +out its timeout: MEASURED 2026-10-27 on a route lookup (``internal_tools`` probes +``2026-10-27-linux-adapters``). +""" +import errno +import socket +import struct +from typing import NamedTuple + +AF_NETLINK = 16 +NETLINK_ROUTE, NETLINK_SOCK_DIAG = 0, 4 +NLMSG_ERROR, NLMSG_DONE = 2, 3 +NLM_F_REQUEST, NLM_F_ACK, NLM_F_DUMP = 0x1, 0x4, 0x300 +NLM_F_DUMP_INTR = 0x10 # the table changed while it was listed +MSG_TRUNC = 0x20 +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) +KERNEL = 0 # the port the kernel sends from + +HEADER = struct.Struct("=IHHII") # nlmsghdr: length, type, flags, seq, port +_ERROR = struct.Struct("=i") # nlmsgerr and NLMSG_DONE: a negative errno, or 0 +_ATTRIBUTE = struct.Struct("=HH") # rtattr / nlattr: length, type +_TYPE = 0x3FFF # an attribute's type without the NESTED and BYTEORDER bits + + +class Replies(list): + """The ``(type, payload)`` pairs of one answer. ``interrupted``: a message of the list + said the table changed while the kernel walked it (NLM_F_DUMP_INTR) - the list may + then miss an entry or hold one twice.""" + interrupted = False + + +def message(kind, flags, body): + """``body`` in its netlink header.""" + return HEADER.pack(HEADER.size + len(body), kind, flags, SEQ, 0) + body + + +def opener(protocol): + """What opens a socket of ``protocol`` for one request.""" + def open_socket(): + nl = socket.socket(AF_NETLINK, socket.SOCK_RAW, protocol) + nl.settimeout(WAIT_S) + return nl + return open_socket + + +def messages(data): + """The netlink messages in one datagram, as ``(type, flags, payload)``.""" + out = [] + offset = 0 + while offset + HEADER.size <= len(data): + length, kind, flags = HEADER.unpack_from(data, offset)[:3] + if length < HEADER.size or offset + length > len(data): + raise OSError(errno.EBADMSG, "netlink: a message longer than its datagram") + out.append((kind, flags, data[offset + HEADER.size:offset + length])) + offset += (length + 3) & ~3 + return out + + +def exchange(request, open_socket): + """Send one request; the kernel's answer up to the message that ends it, as + ``Replies``. A socket per request, closed before this returns.""" + with open_socket() as nl: + nl.sendto(request, (KERNEL, 0)) + replies = Replies() + 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, "netlink: an answer longer than the buffer") + if sender[0] != KERNEL: + raise OSError(errno.EBADMSG, f"netlink: an answer from port {sender[0]}, " + "not the kernel") + for kind, message_flags, payload in messages(data): + replies.interrupted |= bool(message_flags & NLM_F_DUMP_INTR) + replies.append((kind, payload)) + 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"netlink: an answer that ends in type {kind}") + return -_ERROR.unpack_from(payload)[0] + + +class Attribute(NamedTuple): + kind: int + value: bytes + + +def attributes(data, offset=0): + """The attributes of one message from ``offset`` on. One that claims more bytes than + are there is an error, never a shorter value.""" + out = [] + while offset + _ATTRIBUTE.size <= len(data): + length, kind = _ATTRIBUTE.unpack_from(data, offset) + if length < _ATTRIBUTE.size or offset + length > len(data): + raise OSError(errno.EBADMSG, "netlink: an attribute longer than its message") + out.append(Attribute(kind & _TYPE, bytes(data[offset + _ATTRIBUTE.size:offset + length]))) + offset += (length + 3) & ~3 + return out + + +def attribute(kind, value): + """One attribute, padded, for a request.""" + raw = _ATTRIBUTE.pack(_ATTRIBUTE.size + len(value), kind) + value + return raw + bytes(-len(raw) % 4) diff --git a/beantester/nettools/adapters.py b/beantester/nettools/adapters.py index cdc545e1..77ee2f26 100644 --- a/beantester/nettools/adapters.py +++ b/beantester/nettools/adapters.py @@ -29,8 +29,10 @@ # two of the seven adapters on the development machine were one (measured). KINDS = {1: "other", 6: "ethernet", 9: "token_ring", 23: "ppp", 24: "loopback", 37: "atm", 53: "virtual", 71: "wifi", 131: "tunnel", 144: "firewire"} -# NL_PREFIX_ORIGIN: where an address's network part came from. -ORIGINS = {0: "other", 1: "manual", 2: "well_known", 3: "dhcp", 4: "router"} +# NL_PREFIX_ORIGIN: where an address's network part came from - and one of the +# program's own, for a Linux address with a lifetime and no word on who gave it. +ORIGINS = {0: "other", 1: "manual", 2: "well_known", 3: "dhcp", 4: "router", + system.LEASE: "lease"} # NL_DAD_STATE. "preferred" is the normal state and gets no note; "duplicate" is the # one worth a tester's eye - Learn: "a duplicate IP address has been detected". # "tentative" is normal on an adapter that is down (all of them there, measured). @@ -110,7 +112,8 @@ def __init__(self, user_key, detail): # ``adapters.AdaptersUnavailable.reason`` -> what the person is told. -UNREADABLE_KEYS = {"not_ready": "tools.adapters.error_not_ready"} +UNREADABLE_KEYS = {"not_ready": "tools.adapters.error_not_ready", + "changing": "tools.adapters.error_changing"} def supported(): @@ -258,6 +261,11 @@ def details(adapter): if _bare(server) in PLACEHOLDER_DNS: out.append(Detail(f"dns|{n}", Words("tools.adapters.item.dns"), server, (Words("tools.adapters.dns_placeholder"),))) + elif server in row.stub_dns: + # Not offered to the Control page either: impairing it would slow only + # the step to this computer's own resolver, not its questions out. + out.append(Detail(f"dns|{n}", Words("tools.adapters.item.dns"), server, + (Words("tools.adapters.dns_stub"),))) else: out.append(Detail(f"dns|{n}", Words("tools.adapters.item.dns"), server, address=_bare(server))) @@ -292,9 +300,7 @@ def adapter_lines(adapter): note += ", " + _slug(DAD, address.dad) lines.append(f" IPv{address.version}: {address_text(address)} ({note})") lines += [f" gateway: {gateway}" for gateway in row.gateways] - lines += [f" DNS: {server}" + (" (Windows placeholder)" - if _bare(server) in PLACEHOLDER_DNS else "") - for server in row.dns_servers] + lines += [f" DNS: {server}" + _dns_note(row, server) for server in row.dns_servers] dhcp = "on" + (f", server {row.dhcp_server}" if row.dhcp_server else "") if row.dhcp else "off" lines.append(f" DHCP: {dhcp}") if row.dns_suffix: @@ -307,6 +313,14 @@ def adapter_lines(adapter): return lines +def _dns_note(row, server): + if _bare(server) in PLACEHOLDER_DNS: + return " (Windows placeholder)" + if server in row.stub_dns: + return " (systemd-resolved, on this computer)" + return "" + + def _read_at(read_at): return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(read_at)) diff --git a/beantester/nettools/diagnostics.py b/beantester/nettools/diagnostics.py index 6e3ee6c4..9c211fa3 100644 --- a/beantester/nettools/diagnostics.py +++ b/beantester/nettools/diagnostics.py @@ -63,7 +63,9 @@ def diagnose(): """Run the checks and every section's read. Blocking: the first call imports pydivert (~160 ms measured); the adapters add ~3 ms and the proxy ~2 ms (measured 2026-10-13 and 2026-10-14), so they are read with every check rather than when a - box is ticked.""" + box is ticked. On Linux the adapters take under 5 ms, and the proxy is one run of + gsettings: 26 ms typical, 273 ms the first time (measured 2026-10-27) - on the + worker, like the rest.""" ok, rows = driver.doctor() found = {section.id: _found(section) for section in REPORT_SECTIONS if section.read} return Diagnosis(ok, tuple(Check(*row) for row in rows), user_data_dir(), diff --git a/beantester/nettools/proxy.py b/beantester/nettools/proxy.py index be5d20a8..3aa8dad2 100644 --- a/beantester/nettools/proxy.py +++ b/beantester/nettools/proxy.py @@ -79,16 +79,38 @@ def address_of(entry): return "" +def _sends(variable): + """A variable that sends a program to a proxy - on Linux in either case of letters.""" + return variable.name.upper() in PROXY_VARIABLES + + def in_use(reading): """Some program on this machine would be sent to a proxy.""" user, machine = reading.user, reading.machine return bool((user and (user.detect or user.script or user.server)) or (machine and machine.server) - or any(v.name in PROXY_VARIABLES for v in reading.env)) + or any(_sends(v) for v in reading.env)) def unreadable(reading): - return reading.user is None or reading.machine is None or bool(reading.env_error) + """A part could not be read. A part this system does not have (``None`` with no + error) is not a failure.""" + return bool((reading.user is None and reading.user_error) + or (reading.machine is None and reading.machine_error) + or reading.env_error) + + +# What differs by the system the reading is from: where the account's part comes from, +# and who looks for a script when detection is on. +ACCOUNT_ITEM = {"windows": "tools.adapters.proxy.item.account", + "linux": "tools.adapters.proxy.item.desktop"} +DETECT_NOTE = {"windows": "tools.adapters.proxy.note.detect", + "linux": "tools.adapters.proxy.note.detect_linux"} +ACCOUNT_REPORT = {"windows": "proxy, this account", "linux": "proxy, desktop (GNOME)"} +# On Linux a variable's name is case-sensitive, and curl takes the http:// proxy from +# the lowercase one only (its manual: the uppercase one is not read, for CGI's sake). +# On Windows names have no case, and the same curl finds either. +CURL_SKIPS = "HTTP_PROXY" # -- the row on the adapter list ---------------------------------------------------- # @@ -134,12 +156,14 @@ def _failed(key, item, error): def _user_rows(reading): - user = reading.user + user, item = reading.user, ACCOUNT_ITEM[reading.system] + if user is None and reading.user_error: + return _failed("user", item, reading.user_error) if user is None: - return _failed("user", "tools.adapters.proxy.item.account", reading.user_error) + return [Detail("user", Words(item), Words("tools.adapters.proxy.value.not_here"))] yes, no = Words("tools.adapters.value.yes"), Words("tools.adapters.value.no") out = [Detail("detect", Words("tools.adapters.proxy.item.detect"), yes if user.detect else no, - (Words("tools.adapters.proxy.note.detect"),) if user.detect else ()), + (Words(DETECT_NOTE[reading.system]),) if user.detect else ()), Detail("script", Words("tools.adapters.proxy.item.script"), masked(user.script) or Words("tools.adapters.value.none"))] servers = _servers("server", "tools.adapters.proxy.item.server", user.server, ()) @@ -150,6 +174,8 @@ def _user_rows(reading): def _machine_rows(reading): machine = reading.machine + if machine is None and not reading.machine_error: + return [] # Linux: no such place if machine is None: return _failed("winhttp", "tools.adapters.proxy.item.winhttp", reading.machine_error) note = (Words("tools.adapters.proxy.note.services"),) @@ -171,11 +197,13 @@ def _variable_rows(reading): else "tools.adapters.proxy.note.all_accounts")] if variable.replaces: note.append(Words("tools.adapters.proxy.note.replaces")) - note.append(Words("tools.adapters.proxy.note.readers" if variable.name in PROXY_VARIABLES + note.append(Words("tools.adapters.proxy.note.readers" if _sends(variable) else "tools.adapters.proxy.note.no_proxy")) + if reading.system == "linux" and variable.name == CURL_SKIPS: + note.append(Words("tools.adapters.proxy.note.curl_lowercase")) if shown != variable.value: note.append(Words("tools.adapters.proxy.note.masked")) - address = (address_of(variable.value) if variable.name in PROXY_VARIABLES else "") + address = address_of(variable.value) if _sends(variable) else "" out.append(Detail(f"env|{variable.name}", variable.name, shown, tuple(note), address=address)) return out or [Detail("env", Words("tools.adapters.proxy.item.variables"), @@ -196,15 +224,18 @@ def _or_none(text): def report_lines(reading): """The proxy in the copied report: passwords hidden, setup script queries dropped.""" user, machine = reading.user, reading.machine - if user is None: - lines = [f"proxy, this account: could not be read ({reading.user_error})"] + account = ACCOUNT_REPORT[reading.system] + if user is None and reading.user_error: + lines = [f"{account}: could not be read ({reading.user_error})"] + elif user is None: + lines = [f"{account}: not on this system"] else: - lines = [f"proxy, this account: detect automatically {'yes' if user.detect else 'no'}, " + lines = [f"{account}: detect automatically {'yes' if user.detect else 'no'}, " f"setup script {_or_none(user.script)}, server {_or_none(user.server)}, " f"bypass {_or_none(user.bypass)}"] - if machine is None: + if machine is None and reading.machine_error: lines.append(f"proxy for services (WinHTTP): could not be read ({reading.machine_error})") - else: + elif machine is not None: lines.append(f"proxy for services (WinHTTP): {_or_none(machine.server)}" + (f", bypass {for_report(machine.bypass)}" if machine.bypass else "")) if reading.env_error: diff --git a/beantester/paths.py b/beantester/paths.py index db55c99f..0d753aca 100644 --- a/beantester/paths.py +++ b/beantester/paths.py @@ -11,6 +11,7 @@ """ import os import shutil +import stat import sys import tempfile @@ -160,6 +161,56 @@ def hand_back(path): return True +SMALL_FILE = 1 << 16 + + +def read_small_file(path, owner=None, limit=SMALL_FILE): + """The text of a small regular file, or ``None`` when there is none to read. + + For files of the system (``/etc/resolv.conf``) and of a person (their + ``~/.config/environment.d``) that a tool shows. It never waits: a FIFO or a + device put in a file's place would block a read forever, so it is opened + without blocking and refused unless it is a regular file. A dangling link reads + as no file. At most ``limit`` bytes, as UTF-8 with what does not decode replaced. + + ``owner``: only a file that user owns is read. Under ``sudo`` root reads the + person's files, and a link they made there to a file only root may read would + otherwise put that file on their screen. Checked on the file opened, after any + link was followed, so a person's own link to their own file still reads. + """ + flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_CLOEXEC", 0) + try: + fd = os.open(path, flags) + except (FileNotFoundError, NotADirectoryError): + return None + try: + status = os.fstat(fd) + if not stat.S_ISREG(status.st_mode) or (owner is not None and status.st_uid != owner): + return None + chunks, left = [], limit + while left > 0: + chunk = os.read(fd, left) + if not chunk: + break + chunks.append(chunk) + left -= len(chunk) + return b"".join(chunks).decode("utf-8", errors="replace") + finally: + os.close(fd) + + +def home_of(uid): + """The home directory of the account ``uid`` (the password database); None on + Windows and for an account the system does not know.""" + if sys.platform == "win32": + return None + import pwd + try: + return pwd.getpwuid(uid).pw_dir + except KeyError: + return None + + def make_dirs(path): """``os.makedirs(path, exist_ok=True)``, and each level it created handed back. diff --git a/beantester/proxy.py b/beantester/proxy.py index cbbbc2c9..c87eebe6 100644 --- a/beantester/proxy.py +++ b/beantester/proxy.py @@ -25,13 +25,35 @@ Why the structures are written with fixed-width integers: the same as ``adapters.py`` - ``wintypes`` and ``c_wchar_p`` change size and encoding on Linux, where the walk is tested against a fake, so text is read as UTF-16 by hand. + +On Linux (``_Linux``) there is no one place either, and two are read: + +* the desktop's proxy settings (GNOME Settings > Network > Proxy), which Firefox, + Chrome and GNOME's own programs follow, through ``gsettings`` - run as the person + whose desktop it is: under ``sudo`` as root it reads ROOT's settings, which say + "none" (measured 2026-10-27), so it is started as the person who typed ``sudo`` + (``setpriv``). It is the one program this tool starts, and it sends nothing: + traced the same day, its only sockets were local ones to the system's account + database (``internal_tools`` probes ``2026-10-27-linux-adapters``); +* the proxy variables from the files a program started from now on gets them from: + ``/etc/environment`` and the ``environment.d`` directories for every account, the + person's ``~/.config/environment.d`` for theirs. A shell's own startup files + (``~/.bashrc``, ``/etc/profile.d``) are scripts and are not read. The names are + case-sensitive there, and curl reads only the lowercase ``http_proxy``. + +A machine-wide proxy for services, as WinHTTP is, Linux does not have: that part of +the reading stays empty without an error, which says "not on this system". """ import ctypes +import os +import re +import shutil import struct +import subprocess import sys from typing import NamedTuple -from . import crashlog +from . import crashlog, paths ERROR_FILE_NOT_FOUND = 2 # Learn: "No Internet Explorer proxy settings can be found." WINHTTP_ACCESS_TYPE_NAMED_PROXY = 3 @@ -84,12 +106,15 @@ class Variable(NamedTuple): class Reading(NamedTuple): + """Each part, and why it could not be read. A part that is ``None`` with no + error is not on this system (Linux has no WinHTTP; a server has no desktop).""" user: UserProxy | None user_error: str machine: MachineProxy | None machine_error: str env: tuple # of Variable, in ENV_NAMES order env_error: str + system: str = "windows" # whose places these are: "windows" or "linux" NOTHING = UserProxy(False, "", "", "") @@ -166,6 +191,8 @@ def read(root_name, path, names): class _Native: """The three reads. Everything is injectable, so they run in tests anywhere.""" + SYSTEM = "windows" + HAS_MACHINE = True def __init__(self, winhttp=None, kernel32=None, last_error=None, registry=None): self.winhttp = winhttp if winhttp is not None else _winhttp() @@ -225,9 +252,250 @@ def _lookup(values, name): return None +# -- Linux ------------------------------------------------------------------------- # +GNOME_SCHEMA = "org.gnome.system.proxy" +GNOME_SCHEMES = ("http", "https", "ftp", "socks") +# gsettings prints the proxy's account and password with the rest; they are dropped +# as the lines are read and never reach a Reading. +GNOME_SECRETS = frozenset({"authentication-user", "authentication-password"}) +GSETTINGS_WAIT_S = 2.0 # measured: 26 ms typical, 273 ms the first time +CHILD_PATH = "/usr/bin:/bin" +CHILD_LOCALE = "C.UTF-8" # UTF-8 text, and gsettings' own messages in English +ENV_NAMES_LINUX = ("http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", + "all_proxy", "ALL_PROXY", "no_proxy", "NO_PROXY") +ENV_FILE = "etc/environment" # pam_env: every account, at login +# environment.d(5), the lowest first: a file of the same name further down replaces +# one above, and the person's own directory comes last. +ENV_DIRS = ("usr/lib/environment.d", "usr/local/lib/environment.d", "run/environment.d", + "etc/environment.d") +USER_ENV_DIR = os.path.join(".config", "environment.d") +MAX_ENV_FILES = 256 +_ASSIGNMENT = re.compile(r"\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)") +_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "a": "\a", "b": "\b", "f": "\f", "v": "\v"} +_NUMBER = re.compile(r"-?[0-9]+") +_LOOK_UP = object() # _Linux's person: whoever paths.invoking_user() names + + +def _string(text): + """A quoted GVariant string at the start of ``text``, and what follows it.""" + quote, out, i = text[0], [], 1 + while i < len(text): + char = text[i] + if char == quote: + return "".join(out), text[i + 1:] + if char == "\\" and i + 1 < len(text): + code = text[i + 1] + width = {"u": 4, "U": 8}.get(code) + if width: + out.append(chr(int(text[i + 2:i + 2 + width], 16))) + i += 2 + width + else: + out.append(_ESCAPES.get(code, code)) + i += 2 + continue + out.append(char) + i += 1 + raise ValueError("gsettings: a string without its closing quote") + + +def _array(text): + items, rest = [], text[1:].lstrip() + while not rest.startswith("]"): + value, rest = _value(rest) + items.append(value) + rest = rest.lstrip() + if rest.startswith(","): + rest = rest[1:].lstrip() + elif not rest.startswith("]"): + raise ValueError("gsettings: an array that does not close") + return items, rest[1:] + + +def _value(text): + """One value as ``g_variant_print`` writes it - a string, a number, true or false, + an array of them - and what follows it.""" + text = text.lstrip() + if text.startswith("@"): # a type written before an empty value: @as [] + text = text.split(None, 1)[1] if " " in text else "" + if text[:1] in ("'", '"'): + return _string(text) + if text.startswith("["): + return _array(text) + for word, value in (("true", True), ("false", False)): + if text.startswith(word): + return value, text[len(word):] + number = _NUMBER.match(text) + if number: + return int(number.group()), text[number.end():] + raise ValueError(f"gsettings: a value this program does not read: {text[:40]!r}") + + +def gnome_values(text): + """``{(schema, key): value}`` from ``gsettings list-recursively``, without the + proxy's account and password.""" + values = {} + for line in text.splitlines(): + parts = line.split(" ", 2) + if len(parts) != 3 or parts[1] in GNOME_SECRETS: + continue + value, rest = _value(parts[2]) + if rest.strip(): + raise ValueError(f"gsettings: more after a value: {rest[:40]!r}") + values[(parts[0], parts[1])] = value + return values + + +def _host_port(host, port): + host = f"[{host}]" if ":" in host else host + return f"{host}:{port}" if port else host + + +def gnome_proxy(values): + """The desktop's settings as the account part, the way Windows' read: automatic + detection, a setup script, or the servers in Learn's list grammar + (``http=host:port;socks=host:port``), so the rows, the report and the masking + read them unchanged.""" + def get(schema, key, default): + value = values.get((GNOME_SCHEMA + schema, key)) + return default if value is None else value + mode = get("", "mode", "none") + if mode == "none": + return NOTHING + if mode == "auto": # no address: the network is asked (WPAD) + script = get("", "autoconfig-url", "") + return UserProxy(not script, script, "", "") + if mode == "manual": + servers = [f"{scheme}={_host_port(get('.' + scheme, 'host', ''), get('.' + scheme, 'port', 0))}" + for scheme in GNOME_SCHEMES if get("." + scheme, "host", "")] + return UserProxy(False, "", ";".join(servers), ";".join(get("", "ignore-hosts", []))) + raise ValueError(f"gsettings: a proxy mode this program does not know: {mode!r}") + + +def assignments(text): + """``(name, value)`` of each ``NAME=value`` line, outer quotes taken off; comments + and anything else skipped. ``${OTHER}`` is shown as written.""" + out = [] + for line in (text or "").splitlines(): + if line.lstrip().startswith("#"): + continue + found = _ASSIGNMENT.fullmatch(line.rstrip()) + if not found: + continue + value = found.group(2).strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + value = value[1:-1] + out.append((found.group(1), value)) + return out + + +def _trusted_which(name): + """``name`` in the directories the child is given (``CHILD_PATH``), never in this + process's own PATH: under sudo setpriv is started as root, and a PATH with a + directory the person can write to would let them choose what root runs.""" + return shutil.which(name, path=CHILD_PATH) + + +def _run(argv, env): + """The one program this tool starts: gsettings (the docstring at the top says why, + and what it was measured to send - nothing). Nothing of this process reaches it - + not its terminal, its files or its environment - and it may take ``GSETTINGS_WAIT_S``.""" + return subprocess.run(argv, stdin=subprocess.DEVNULL, capture_output=True, + timeout=GSETTINGS_WAIT_S, env=env, check=False) + + +class _Linux: + """The desktop's settings and the variables' files. Who is asked - ``person``, + ``(uid, gid)`` of whoever typed ``sudo`` - how a program is run and found, and + where the files are are injectable, so this runs in tests anywhere.""" + SYSTEM = "linux" + HAS_MACHINE = False + + def __init__(self, run=None, which=None, root="/", person=_LOOK_UP, own_uid=None, + home=None): + self.run = run or _run + self.which = which or _trusted_which + self.root = root + self.person = paths.invoking_user() if person is _LOOK_UP else person + # geteuid exists off Windows only; there a test brings its own. + self.own_uid = own_uid if own_uid is not None else getattr(os, "geteuid", lambda: 0)() + self.uid = self.person[0] if self.person else self.own_uid + # "" is a home that is not there, not a home to look up. + self.home = home if home is not None else paths.home_of(self.uid) + + # -- the desktop's settings ---------------------------------------------- # + def user(self): + """GNOME's proxy settings; None where there is no GNOME to ask.""" + text = self._gsettings() + return None if text is None else gnome_proxy(gnome_values(text)) + + def _gsettings(self): + program = self.which("gsettings") + if not program: + return None + if not self.home: + raise OSError(f"no home directory for the account {self.uid}") + argv = [program, "list-recursively", GNOME_SCHEMA] + if self.uid != self.own_uid: + setpriv = self.which("setpriv") + if not setpriv: + raise OSError("setpriv was not found, so the desktop of the person who " + "typed sudo cannot be asked") + argv = [setpriv, f"--reuid={self.person[0]}", f"--regid={self.person[1]}", + "--init-groups", *argv] + done = self.run(argv, {"HOME": self.home, "PATH": CHILD_PATH, "LC_ALL": CHILD_LOCALE}) + said = done.stderr.decode("utf-8", errors="replace") + # No schema: not a GNOME system. The "memory" backend: GLib has nowhere the + # desktop keeps its settings, and answers with the defaults. + if "No such schema" in said or "'memory' GSettings backend" in said: + return None + if done.returncode: + first = said.strip().splitlines()[0] if said.strip() else "" + raise OSError(f"gsettings answered {done.returncode}: {first}") + return done.stdout.decode("utf-8", errors="replace") + + # -- the variables ------------------------------------------------------- # + def env(self): + values, machine = {}, set() + sources = [(os.path.join(self.root, ENV_FILE), "machine", None), *self._env_files()] + for path, scope, owner in sources: + for name, value in assignments(paths.read_small_file(path, owner=owner)): + if name not in ENV_NAMES_LINUX: + continue + if scope == "machine": + machine.add(name) + values[name] = Variable(name, value, scope, + replaces=scope == "user" and name in machine) + # `http_proxy=` unsets it - the last word wins, and an empty one says "none", as + # an empty registry value does on Windows. + return tuple(values[name] for name in ENV_NAMES_LINUX + if name in values and values[name].value) + + def _env_files(self): + chosen = {} + for directory in ENV_DIRS: + for name in _conf_names(os.path.join(self.root, directory)): + chosen[name] = (os.path.join(self.root, directory, name), "machine", None) + if self.home: + own = os.path.join(self.home, USER_ENV_DIR) + for name in _conf_names(own, owner=self.uid): + chosen[name] = (os.path.join(own, name), "user", self.uid) + return [chosen[name] for name in sorted(chosen)][:MAX_ENV_FILES] + + +def _conf_names(directory, owner=None): + """The ``*.conf`` names in ``directory``; none when it is missing, or - for a + person's own directory read by root - when it is not theirs.""" + try: + if owner is not None and os.stat(directory).st_uid != owner: + return [] + return sorted(n for n in os.listdir(directory) if n.endswith(".conf"))[:MAX_ENV_FILES] + except (FileNotFoundError, NotADirectoryError): + return [] + + def supported(): - """Proxy settings are read on Windows only, like the adapters they sit beside.""" - return sys.platform.startswith("win") + """Proxy settings are read on Windows and on Linux, like the adapters they sit beside.""" + return sys.platform.startswith(("win", "linux")) def _part(read, area): @@ -244,10 +512,16 @@ def read(native=None): adapter list is read in the same job, and a proxy that cannot be read must not cost it (a library that will not load included).""" if native is None: - native, why = _part(_Native, "proxy") + linux = sys.platform.startswith("linux") + native, why = _part(_Linux if linux else _Native, "proxy") if native is None: - return Reading(None, why, None, why, (), why) + return Reading(None, why, None, "" if linux else why, (), why, + "linux" if linux else "windows") + # A reader that does not say otherwise has Windows' three places. + has_machine = getattr(native, "HAS_MACHINE", True) user, user_error = _part(native.user, "proxy.user") - machine, machine_error = _part(native.machine, "proxy.machine") + machine, machine_error = (_part(native.machine, "proxy.machine") if has_machine + else (None, "")) env, env_error = _part(native.env, "proxy.env") - return Reading(user, user_error, machine, machine_error, env or (), env_error) + return Reading(user, user_error, machine, machine_error, env or (), env_error, + getattr(native, "SYSTEM", "windows")) diff --git a/beantester/rtnetlink.py b/beantester/rtnetlink.py new file mode 100644 index 00000000..01b3488c --- /dev/null +++ b/beantester/rtnetlink.py @@ -0,0 +1,252 @@ +"""The kernel's tables of network adapters, their addresses and its routes on Linux - read only. + +What ``ip link``, ``ip addr`` and ``ip route`` read, asked the way they ask it: rtnetlink, +over ``netlink``. For the Adapters tool (``adapters._Linux``), on a worker. Nothing here +changes a table, and nothing is sent: a list is a question to the kernel, and so is a +route lookup (``route_to``, what ``ip route get`` asks) - the kernel answers from its +routing table and no packet leaves. + +The layouts are the kernel's (``include/uapi/linux``: ``rtnetlink.h``, ``if_link.h``, +``if_addr.h``), which never change: ``ifinfomsg``, ``ifaddrmsg`` and ``rtmsg``, each +followed by attributes. The numbers are written out, errnos included: Python's +``errno`` on Windows numbers them as Windows does (ENETUNREACH is 118 there, 101 here), +and the tests run there too. + +MEASURED 2026-10-27 (``internal_tools`` probes ``2026-10-27-linux-adapters``: Ubuntu 26.04.1 +with kernel 7.0.0, a network namespace holding veth, dummy, bridge, vlan, tun and +wireguard, and WSL with 6.18): every field read here matched ``ip -j`` - names, MTU, +state, MAC, kind, addresses with their prefix, lifetime and DAD state, the route out. +The three lists and two route lookups took 0.4-0.9 ms. An ordinary user gets all of +it; ``tests/test_rtnetlink.py`` holds that kernel's own answers. +""" +import ipaddress +import struct +from typing import NamedTuple + +from . import netlink +from .netlink import NETLINK_ROUTE, NLM_F_ACK, NLM_F_DUMP, NLM_F_REQUEST + +RTM_NEWLINK, RTM_GETLINK = 16, 18 +RTM_NEWADDR, RTM_GETADDR = 20, 22 +RTM_NEWROUTE, RTM_GETROUTE = 24, 26 +LINUX_AF_UNSPEC, LINUX_AF_INET, LINUX_AF_INET6 = 0, 2, 10 + +# Linux's errnos, not this interpreter's (see above). +EAFNOSUPPORT = 97 # the kernel has no IPv6 (booted with ipv6.disable=1) +NO_ROUTE = frozenset({100, 101, 113}) # ENETDOWN, ENETUNREACH, EHOSTUNREACH + +_LINK = struct.Struct("=BxHiII") # ifinfomsg: family, pad, type (ARPHRD), index, flags, change +_ADDRESS = struct.Struct("=BBBBI") # ifaddrmsg: family, prefix length, flags, scope, index +_ROUTE = struct.Struct("=BBBBBBBBI") # rtmsg: family, dst_len, src_len, tos, table, protocol, + # scope, type, flags +_NEXTHOP = struct.Struct("=HBBi") # rtnexthop: length, flags, hops, interface +_U32 = struct.Struct("=I") +_LIFETIMES = struct.Struct("=II") # ifa_cacheinfo, its first two: preferred, valid + +IFLA_ADDRESS, IFLA_IFNAME, IFLA_MTU, IFLA_OPERSTATE, IFLA_LINKINFO = 1, 3, 4, 16, 18 +IFLA_INFO_KIND = 1 +IFA_ADDRESS, IFA_LOCAL, IFA_CACHEINFO, IFA_FLAGS, IFA_PROTO = 1, 2, 6, 8, 11 +RTA_DST, RTA_OIF, RTA_GATEWAY, RTA_PRIORITY, RTA_MULTIPATH, RTA_TABLE, RTA_VIA = ( + 1, 4, 5, 6, 9, 15, 18) + +FOREVER = 0xFFFFFFFF # a lifetime without an end +TRIES = 3 # lists read again when one changed while it was read + + +class Link(NamedTuple): + ifindex: int + name: bytes # as the kernel holds it: any byte but "/", ":", whitespace and NUL + arphrd: int # the link layer (ARPHRD_*): 1 Ethernet, 772 loopback, 65534 none + flags: int # IFF_* + operstate: int # IF_OPER_*: 0 unknown ... 6 up + mtu: int + address: bytes # the hardware address; b"" when the link layer has none + kind: str # IFLA_INFO_KIND of a virtual device ("veth", "bridge"); "" otherwise + + +class Address(NamedTuple): + ifindex: int + family: int # LINUX_AF_INET / LINUX_AF_INET6 + ip: str + prefix: int + flags: int # IFA_F_*, all 32 bits + scope: int + preferred: int # seconds; FOREVER + valid: int + proto: int # IFA_PROTO (kernel 6.3 and later): who made it; 0 when not said + + +class Route(NamedTuple): + family: int + dst_len: int # 0: a default route + table: int + protocol: int # RTPROT_*: 2 kernel, 3 boot, 4 static, 16 dhcp... + kind: int # RTN_*: 1 unicast; blackhole, unreachable and prohibit have no way out + oif: int # 0: none + gateway: str # "" for a route to a network the adapter is on + priority: int | None # the metric; None when not given + + +class Tables(NamedTuple): + links: tuple + addresses: tuple + routes: tuple + + +class Changing(OSError): + """Every try read a list the kernel said changed while it was read.""" + + +_open = netlink.opener(NETLINK_ROUTE) + + +def exchange(request): + """One request to the kernel's routing tables, through ``netlink.exchange``.""" + return netlink.exchange(request, _open) + + +def _ip(raw): + return str(ipaddress.ip_address(raw)) if len(raw) in (4, 16) else "" + + +def _u32(raw): + return _U32.unpack_from(raw)[0] if len(raw) >= _U32.size else 0 + + +def _text(raw): + return raw.split(b"\0", 1)[0].decode("ascii", errors="replace") + + +def _dump(kind, body, talk, what): + """One list, its messages of the answer's own type, and whether it changed meanwhile.""" + replies = talk(netlink.message(kind, NLM_F_REQUEST | NLM_F_DUMP, body)) + code = netlink.error_of(replies) + if code: + raise OSError(code, f"rtnetlink: the kernel refused the list of {what}") + answer = kind - 2 # RTM_GET* is answered with RTM_NEW* + return [payload for k, payload in replies if k == answer], bool( + getattr(replies, "interrupted", False)) + + +def _link(payload): + _family, arphrd, index, flags, _change = _LINK.unpack_from(payload) + fields = {IFLA_IFNAME: b"", IFLA_ADDRESS: b"", IFLA_MTU: b"", IFLA_OPERSTATE: b"\0"} + kind = "" + for attribute in netlink.attributes(payload, _LINK.size): + if attribute.kind == IFLA_LINKINFO: + kind = next((_text(a.value) for a in netlink.attributes(attribute.value) + if a.kind == IFLA_INFO_KIND), "") + elif attribute.kind in fields: + fields[attribute.kind] = attribute.value + return Link(index, fields[IFLA_IFNAME].split(b"\0", 1)[0], arphrd, flags, + fields[IFLA_OPERSTATE][0] if fields[IFLA_OPERSTATE] else 0, + _u32(fields[IFLA_MTU]), fields[IFLA_ADDRESS], kind) + + +def _address(payload): + family, prefix, flags, scope, index = _ADDRESS.unpack_from(payload) + found = {} + for attribute in netlink.attributes(payload, _ADDRESS.size): + found[attribute.kind] = attribute.value + preferred, valid = (_LIFETIMES.unpack_from(found[IFA_CACHEINFO]) + if len(found.get(IFA_CACHEINFO, b"")) >= _LIFETIMES.size + else (FOREVER, FOREVER)) + # IFA_LOCAL is this machine's address; IFA_ADDRESS is the far end's on a + # point-to-point link, and the only one IPv6 sends - as ``ip`` reads them. + raw = found.get(IFA_LOCAL) or found.get(IFA_ADDRESS, b"") + # IFA_FLAGS carries all 32 bits; the byte in the header only the low eight. + flags = _u32(found[IFA_FLAGS]) if IFA_FLAGS in found else flags + proto = found[IFA_PROTO][0] if found.get(IFA_PROTO) else 0 + return Address(index, family, _ip(raw), prefix, flags, scope, preferred, valid, proto) + + +def _hops(raw): + """The ``(interface, gateway)`` of each way of a route with several (RTA_MULTIPATH).""" + out, offset = [], 0 + while offset + _NEXTHOP.size <= len(raw): + length, _flags, _hops_count, oif = _NEXTHOP.unpack_from(raw, offset) + if length < _NEXTHOP.size: + break + inner = netlink.attributes(raw[offset + _NEXTHOP.size:offset + length]) + gateway = next((_via(a) for a in inner if a.kind in (RTA_GATEWAY, RTA_VIA)), "") + out.append((oif, gateway)) + offset += (length + 3) & ~3 + return out + + +def _via(attribute): + """RTA_GATEWAY is an address; RTA_VIA a family and then the address (an IPv4 route + through an IPv6 neighbour).""" + return _ip(attribute.value[2:] if attribute.kind == RTA_VIA else attribute.value) + + +def _routes(payload): + """One route message as ``Route`` rows: one per way out.""" + family, dst_len, _src, _tos, table, protocol, _scope, kind, _flags = _ROUTE.unpack_from(payload) + oif, gateway, priority, hops = 0, "", None, [] + for attribute in netlink.attributes(payload, _ROUTE.size): + if attribute.kind == RTA_OIF: + oif = _u32(attribute.value) + elif attribute.kind in (RTA_GATEWAY, RTA_VIA): + gateway = _via(attribute) + elif attribute.kind == RTA_PRIORITY: + priority = _u32(attribute.value) + elif attribute.kind == RTA_TABLE: + table = _u32(attribute.value) # a table above 255 is only here + elif attribute.kind == RTA_MULTIPATH: + hops = _hops(attribute.value) + return [Route(family, dst_len, table, protocol, kind, way, via, priority) + for way, via in (hops or [(oif, gateway)])] + + +def _route_list(family, talk): + try: + payloads, interrupted = _dump(RTM_GETROUTE, _ROUTE.pack(family, 0, 0, 0, 0, 0, 0, 0, 0), + talk, "routes") + except OSError as exc: + if family == LINUX_AF_INET6 and exc.errno == EAFNOSUPPORT: + return [], False # no IPv6 in this kernel: no IPv6 route + raise + return [route for payload in payloads for route in _routes(payload)], interrupted + + +def _read_once(talk): + links, a = _dump(RTM_GETLINK, _LINK.pack(LINUX_AF_UNSPEC, 0, 0, 0, 0), talk, "adapters") + addresses, b = _dump(RTM_GETADDR, _ADDRESS.pack(LINUX_AF_UNSPEC, 0, 0, 0, 0), talk, + "addresses") + v4, c = _route_list(LINUX_AF_INET, talk) + v6, d = _route_list(LINUX_AF_INET6, talk) + tables = Tables(tuple(_link(p) for p in links), tuple(_address(p) for p in addresses), + tuple(v4 + v6)) + return tables, a or b or c or d + + +def read(talk=exchange): + """The adapters, their addresses and every route, read again when the kernel said a + list changed while it was read. Raises ``Changing`` after ``TRIES`` such reads, and + ``OSError`` when the kernel refuses a list.""" + for _ in range(TRIES): + tables, interrupted = _read_once(talk) + if not interrupted: + return tables + raise Changing(11, "rtnetlink: the lists kept changing while they were read") + + +def route_to(ip, talk=exchange): + """The interface index the kernel would send a packet to ``ip`` through - what + ``ip route get`` asks; nothing is sent. 0 when there is no route.""" + address = ipaddress.ip_address(ip) + family = LINUX_AF_INET if address.version == 4 else LINUX_AF_INET6 + body = (_ROUTE.pack(family, address.max_prefixlen, 0, 0, 0, 0, 0, 0, 0) + + netlink.attribute(RTA_DST, address.packed)) + # NLM_F_ACK: without it the route comes with nothing after it (netlink's docstring). + replies = talk(netlink.message(RTM_GETROUTE, NLM_F_REQUEST | NLM_F_ACK, body)) + code = netlink.error_of(replies) + if code in NO_ROUTE: + return 0 + if code: + raise OSError(code, f"rtnetlink: the kernel refused a route lookup ({code})") + for kind, payload in replies: + if kind == RTM_NEWROUTE: + return _routes(payload)[0].oif + return 0 diff --git a/beantester/sockdiag.py b/beantester/sockdiag.py index 1ea40fd7..7d339871 100644 --- a/beantester/sockdiag.py +++ b/beantester/sockdiag.py @@ -28,16 +28,17 @@ """ import errno import ipaddress -import socket import struct from typing import NamedTuple -AF_NETLINK = 16 -NETLINK_SOCK_DIAG = 4 +from . import netlink + +# The transport's numbers, under the names tcpreset and the tests read them by. +NETLINK_SOCK_DIAG = netlink.NETLINK_SOCK_DIAG +NLMSG_ERROR, NLMSG_DONE = netlink.NLMSG_ERROR, netlink.NLMSG_DONE +NLM_F_REQUEST, NLM_F_ACK, NLM_F_DUMP = netlink.NLM_F_REQUEST, netlink.NLM_F_ACK, netlink.NLM_F_DUMP +MSG_TRUNC = netlink.MSG_TRUNC 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 @@ -45,9 +46,6 @@ # 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 @@ -56,12 +54,10 @@ 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 @@ -89,49 +85,20 @@ def sockid(sport=0, dport=0, src=b"", dst=b"", interface=0, cookie=NO_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 + return netlink.message(kind, flags, body) -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 +_open = netlink.opener(NETLINK_SOCK_DIAG) 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] + an error of 0). ``netlink.exchange`` over a sock_diag socket.""" + return netlink.exchange(message, opener) + + +error_of = netlink.error_of def _address(packed): diff --git a/lang/en.json b/lang/en.json index 3b32087a..b34a1870 100644 --- a/lang/en.json +++ b/lang/en.json @@ -664,6 +664,7 @@ "tips.tools_adapters_col_item": "What the row is about.", "tips.tools_adapters_col_kind": "What kind of adapter it is: Ethernet, Wi-Fi, a tunnel (often a VPN) or a virtual one.", "tips.tools_adapters_col_name": "The adapter's name, as Windows Settings shows it.", + "tips.tools_adapters_col_name_linux": "The adapter's name, as ip link shows it.", "tips.tools_adapters_col_note": "Where an address came from, and a warning when another device uses the same address.", "tips.tools_adapters_col_status": "Up means the adapter can carry traffic. Click ? for what each status means.", "tips.tools_adapters_col_value": "What the system says. Right-click a gateway or a DNS server to limit the impairments to it.", @@ -675,8 +676,9 @@ "tips.tools_diagnostics_clean": "Unloads a WinDivert driver that was left loaded. Not available while a session is running.", "tips.tools_diagnostics_copy": "Copies the version and every check, in English, ready to paste into the Environment report field of a bug report.", "tips.tools_diagnostics_help": "What each check means, and what to do about it", - "tips.tools_diagnostics_not_here": "Not available on this system yet. This part of the report comes from the Adapters tool, which works on Windows only for now.", + "tips.tools_diagnostics_not_here": "Not available on this system yet. This part of the report comes from the Adapters tool, which does not work on this system yet.", "tips.tools_diagnostics_proxy": "Adds the proxy settings to the copied report: this account's, the services' (WinHTTP) and the proxy variables, with passwords hidden. Off until you tick it - read the text before you paste it into a public bug report.", + "tips.tools_diagnostics_proxy_linux": "Adds the proxy settings to the copied report: the desktop's (GNOME) and the proxy variables, with passwords hidden. Off until you tick it - read the text before you paste it into a public bug report.", "tips.tools_exprtest_expression": "The expression, written as on the Control page. A comma separates the parts, a dash makes a range and \"!\" excludes. Click ? for examples.", "tips.tools_exprtest_field": "The Control-page field whose expression you are testing. It decides what the value is: an address, a port or a process.", "tips.tools_exprtest_help": "How the filter tester works, with examples", @@ -748,11 +750,14 @@ "tools.adapters.dad.invalid": "not usable", "tools.adapters.dad.tentative": "not confirmed yet", "tools.adapters.dns_placeholder": "Windows placeholder, not a server set for this adapter", + "tools.adapters.dns_stub": "systemd-resolved on this computer: it asks the servers listed at the other adapters", "tools.adapters.empty": "The system lists no network adapters.", "tools.adapters.empty_details": "Choose an adapter above to see its details.", "tools.adapters.empty_failed": "The adapter list could not be read. The line above says why. Press Refresh to try again.", + "tools.adapters.error_changing": "the list of adapters kept changing while it was read. Press Refresh in a moment.", "tools.adapters.error_not_ready": "an adapter is still getting its address (DHCP), so the system did not list the adapters. Press Refresh in a moment.", "tools.adapters.help_body": "Every network adapter on this computer, with what ipconfig /all lists for it. The adapter the internet goes through is at the top, then the ones that are up.\n\nThe list is read when you open this tab and when you press Refresh. The status line says when. Nothing is sent: the list and the route to the internet come from tables Windows keeps.\n\nColumns:\n status - Up means the adapter can carry traffic. Down, Not present and Lower layer down mean it cannot.\n type - Ethernet, Wi-Fi, Loopback (this computer talking to itself), Tunnel (often a VPN), Virtual (Hyper-V, WSL, a VPN client).\n internet - traffic to the internet leaves through this adapter, for IPv4, IPv6 or both.\n\nChoose an adapter to see its details below: every address with its prefix, the gateway, the DNS servers, DHCP, the MAC address, the MTU and the link speed. The note next to an address says where it came from, and warns when another device on the network uses the same address.\n\nAn adapter without DNS servers of its own lists three that start with fec0:. Windows puts them there as placeholders, and ipconfig does not show them.\n\nRight-click a gateway or a DNS server to limit the impairments to that address. That only fills in the IP field of Destination targeting on the Control page: nothing changes until you press START or Apply changes.\n\nThe first row, Proxy, shows whether programs on this computer go through a proxy. Choose it to see this account's settings (automatic detection, a setup script, a proxy server and the names that go around it), the proxy Windows services use (WinHTTP) and the proxy variables that curl, git, Python, Node and Go read. A program that uses a proxy connects to the proxy, not to your target: to impair its traffic, right-click the proxy server and limit the impairments to its address. A server given as a name is not looked up, and the tool does not look for the script automatic detection would find - nothing is sent. Passwords in an address are hidden.\n\n\"Copy report\" copies every adapter and the proxy settings, in English. The text contains this computer's addresses and MAC addresses - read it before you paste it anywhere public. Right-click an adapter in the list to copy only that one, in the same words.", + "tools.adapters.help_linux": "Every network adapter on this computer, with what ip addr and ip route show for it. The adapter the internet goes through is at the top, then the ones that are up.\n\nThe list is read when you open this tab and when you press Refresh. The status line says when. Nothing is sent: the list and the route to the internet come from tables the Linux kernel keeps, and reading them needs no sudo.\n\nColumns:\n status - Up means the adapter can carry traffic. Down, Not present and Lower layer down mean it cannot. The loopback, a dummy adapter and a WireGuard tunnel are Up when they are switched on and have a link: their drivers report no state, and ip shows them as UNKNOWN.\n type - Ethernet, Wi-Fi, Loopback (this computer talking to itself), Tunnel (often a VPN), Virtual (a bridge, a veth pair, a container's network).\n internet - traffic to the internet leaves through this adapter, for IPv4, IPv6 or both.\n\nChoose an adapter to see its details below: every address with its prefix, the gateway, the DNS servers, DHCP, the MAC address, the MTU and the link speed. The note next to an address says where it came from, and warns when another device on the network uses the same address. \"With a lease\" is an IPv6 address given for a limited time: the kernel does not say whether DHCPv6 or the router gave it. DHCP is Yes when an IPv4 address has a lease or the default route came from DHCP. The metric is the default route's.\n\nEach DNS server is listed at the adapter its questions leave through. When systemd-resolved answers for this computer, 127.0.0.53 is listed at the loopback: it asks the servers listed at the other adapters.\n\nRight-click a gateway or a DNS server to limit the impairments to that address. That only fills in the IP field of Destination targeting on the Control page: nothing changes until you press START or Apply changes.\n\nThe first row, Proxy, shows whether programs on this computer go through a proxy. Choose it to see the desktop's proxy settings (GNOME Settings, which Firefox, Chrome and GNOME's programs follow) and the proxy variables in /etc/environment and the environment.d folders (every account) and in ~/.config/environment.d (yours). Variables set in a shell's startup files, such as ~/.bashrc, are not read. On Linux the case of a variable's name matters, and curl reads only the lowercase http_proxy. A program that uses a proxy connects to the proxy, not to your target: to impair its traffic, right-click the proxy server and limit the impairments to its address. A server given as a name is not looked up, and the tool does not look for the script automatic detection would find - nothing is sent. Passwords in an address are hidden. The desktop's settings are read with gsettings, as you, also when the program runs under sudo.\n\n\"Copy report\" copies every adapter and the proxy settings, in English. The text contains this computer's addresses and MAC addresses - read it before you paste it anywhere public. Right-click an adapter in the list to copy only that one, in the same words.", "tools.adapters.help_title": "Network adapters", "tools.adapters.item.addresses": "Addresses", "tools.adapters.item.description": "Device", @@ -789,12 +794,14 @@ "tools.adapters.log_limited": "Destination IP set from the Adapters tool", "tools.adapters.note_stale": "The rows below were read at {time}.", "tools.adapters.origin.dhcp": "DHCP", + "tools.adapters.origin.lease": "with a lease (DHCPv6 or the router)", "tools.adapters.origin.manual": "set by hand", "tools.adapters.origin.other": "other source", "tools.adapters.origin.router": "router advertisement", "tools.adapters.origin.well_known": "assigned by the system", "tools.adapters.proxy.item.account": "Proxy of this account", "tools.adapters.proxy.item.bypass": "Not through the proxy", + "tools.adapters.proxy.item.desktop": "Proxy of the desktop (GNOME)", "tools.adapters.proxy.item.detect": "Detect settings automatically", "tools.adapters.proxy.item.script": "Setup script", "tools.adapters.proxy.item.server": "Proxy server", @@ -802,7 +809,9 @@ "tools.adapters.proxy.item.winhttp": "Proxy for services (WinHTTP)", "tools.adapters.proxy.item.winhttp_bypass": "Not through it (WinHTTP)", "tools.adapters.proxy.note.all_accounts": "all accounts", + "tools.adapters.proxy.note.curl_lowercase": "curl does not read this one, only http_proxy", "tools.adapters.proxy.note.detect": "Windows looks for a script on the network", + "tools.adapters.proxy.note.detect_linux": "programs look for a script on the network", "tools.adapters.proxy.note.goes_here": "programs using it connect here, not to your target", "tools.adapters.proxy.note.local": " = names without a dot", "tools.adapters.proxy.note.masked": "password hidden", @@ -816,6 +825,7 @@ "tools.adapters.proxy.state.in_use": "In use", "tools.adapters.proxy.state.not_set": "Not set", "tools.adapters.proxy.state.unreadable": "Could not be read", + "tools.adapters.proxy.value.not_here": "not on this system", "tools.adapters.refresh": "Refresh", "tools.adapters.report_logged": "network adapters report", "tools.adapters.status.code": "Status {code}", diff --git a/lang/pl.json b/lang/pl.json index 8ac62a6e..ad97bf80 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -664,6 +664,7 @@ "tips.tools_adapters_col_item": "Czego dotyczy wiersz.", "tips.tools_adapters_col_kind": "Rodzaj karty: Ethernet, Wi-Fi, tunel (często VPN) albo karta wirtualna.", "tips.tools_adapters_col_name": "Nazwa karty, taka jak w Ustawieniach Windows.", + "tips.tools_adapters_col_name_linux": "Nazwa karty, taka jak pokazuje ip link.", "tips.tools_adapters_col_note": "Skąd pochodzi adres, i ostrzeżenie, gdy inne urządzenie używa tego samego adresu.", "tips.tools_adapters_col_status": "Działa znaczy, że karta może przesyłać ruch. Kliknij ?, żeby zobaczyć, co znaczy każdy stan.", "tips.tools_adapters_col_value": "To, co podaje system. Kliknij prawym przyciskiem bramę albo serwer DNS, żeby ograniczyć do niego zakłócenia.", @@ -675,8 +676,9 @@ "tips.tools_diagnostics_clean": "Wyładowuje sterownik WinDivert, który pozostał załadowany. Niedostępne w trakcie sesji.", "tips.tools_diagnostics_copy": "Kopiuje wersję i wszystkie sprawdzenia, po angielsku, gotowe do wklejenia w pole Environment report zgłoszenia błędu.", "tips.tools_diagnostics_help": "Co znaczy każde sprawdzenie i co z nim zrobić", - "tips.tools_diagnostics_not_here": "Jeszcze niedostępne na tym systemie. Ta część raportu pochodzi z narzędzia Karty sieciowe, które na razie działa tylko w Windows.", + "tips.tools_diagnostics_not_here": "Jeszcze niedostępne na tym systemie. Ta część raportu pochodzi z narzędzia Karty sieciowe, które na tym systemie jeszcze nie działa.", "tips.tools_diagnostics_proxy": "Dołącza do kopiowanego raportu ustawienia proxy: tego konta, usług (WinHTTP) i zmienne proxy, z ukrytymi hasłami. Wyłączone, dopóki tego nie zaznaczysz - przeczytaj tekst, zanim wkleisz go do publicznego zgłoszenia błędu.", + "tips.tools_diagnostics_proxy_linux": "Dołącza do kopiowanego raportu ustawienia proxy: pulpitu (GNOME) i zmienne proxy, z ukrytymi hasłami. Wyłączone, dopóki tego nie zaznaczysz - przeczytaj tekst, zanim wkleisz go do publicznego zgłoszenia błędu.", "tips.tools_exprtest_expression": "Wyrażenie, zapisane jak na stronie Sterowanie. Przecinek oddziela części, myślnik tworzy zakres, a „!” wyklucza. Przykłady pod przyciskiem ?.", "tips.tools_exprtest_field": "Pole ze strony Sterowanie, którego wyrażenie sprawdzasz. Od niego zależy, czym jest wartość: adresem, portem czy procesem.", "tips.tools_exprtest_help": "Jak działa tester wyrażeń, z przykładami", @@ -748,11 +750,14 @@ "tools.adapters.dad.invalid": "nie do użycia", "tools.adapters.dad.tentative": "jeszcze niepotwierdzony", "tools.adapters.dns_placeholder": "zastępczy adres Windows, nie serwer ustawiony dla tej karty", + "tools.adapters.dns_stub": "systemd-resolved na tym komputerze: pyta serwery pokazane przy innych kartach", "tools.adapters.empty": "System nie wymienia żadnej karty sieciowej.", "tools.adapters.empty_details": "Wybierz kartę powyżej, żeby zobaczyć jej szczegóły.", "tools.adapters.empty_failed": "Nie udało się odczytać listy kart. Linia powyżej mówi dlaczego. Naciśnij Odśwież, żeby spróbować jeszcze raz.", + "tools.adapters.error_changing": "lista kart zmieniała się w trakcie odczytu. Naciśnij Odśwież za chwilę.", "tools.adapters.error_not_ready": "karta wciąż dostaje adres (DHCP), więc system nie podał listy kart. Naciśnij Odśwież za chwilę.", "tools.adapters.help_body": "Każda karta sieciowa tego komputera, z tym, co podaje dla niej ipconfig /all. Na górze jest karta, przez którą idzie internet, potem te, które działają.\n\nLista jest odczytywana, gdy otwierasz tę kartę i gdy naciśniesz Odśwież. Pasek stanu mówi, kiedy. Nic nie jest wysyłane: lista i trasa do internetu pochodzą z tablic, które prowadzi Windows.\n\nKolumny:\n stan - Działa znaczy, że karta może przesyłać ruch. Nie działa, Brak urządzenia i Nie działa warstwa niżej znaczą, że nie może.\n typ - Ethernet, Wi-Fi, Pętla zwrotna (komputer rozmawia sam ze sobą), Tunel (często VPN), Wirtualna (Hyper-V, WSL, klient VPN).\n internet - ruch do internetu wychodzi przez tę kartę, dla IPv4, IPv6 albo obu.\n\nWybierz kartę, żeby zobaczyć poniżej jej szczegóły: każdy adres z prefiksem, bramę, serwery DNS, DHCP, adres MAC, MTU i szybkość łącza. Uwaga przy adresie mówi, skąd pochodzi, i ostrzega, gdy inne urządzenie w sieci używa tego samego adresu.\n\nKarta bez własnych serwerów DNS wymienia trzy zaczynające się od fec0:. Windows wpisuje je jako adresy zastępcze, a ipconfig ich nie pokazuje.\n\nKliknij prawym przyciskiem bramę albo serwer DNS, żeby ograniczyć zakłócenia do tego adresu. To tylko wypełnia pole IP w sekcji Celuj w adres docelowy na karcie Sterowanie: nic się nie zmieni, dopóki nie naciśniesz START albo Zastosuj zmiany.\n\nPierwszy wiersz, Proxy, pokazuje, czy programy na tym komputerze łączą się przez serwer proxy. Wybierz go, żeby zobaczyć ustawienia tego konta (automatyczne wykrywanie, skrypt instalacyjny, serwer proxy i nazwy, które go omijają), proxy usług Windows (WinHTTP) oraz zmienne proxy, które czytają curl, git, Python, Node i Go. Program, który używa proxy, łączy się z serwerem proxy, a nie z Twoim celem: żeby psuć jego ruch, kliknij prawym przyciskiem serwer proxy i ogranicz zakłócenia do jego adresu. Serwer podany nazwą nie jest sprawdzany, a narzędzie nie szuka skryptu, który znalazłoby automatyczne wykrywanie - nic nie jest wysyłane. Hasła w adresach są ukryte.\n\n„Kopiuj raport” kopiuje wszystkie karty i ustawienia proxy, po angielsku. Tekst zawiera adresy i adresy MAC tego komputera - przeczytaj go, zanim wkleisz go gdziekolwiek publicznie. Kliknij prawym przyciskiem kartę na liście, żeby skopiować tylko ją, w tej samej postaci.", + "tools.adapters.help_linux": "Każda karta sieciowa tego komputera, z tym, co pokazują dla niej ip addr i ip route. Na górze jest karta, przez którą idzie internet, potem te, które działają.\n\nLista jest odczytywana, gdy otwierasz tę kartę i gdy naciśniesz Odśwież. Pasek stanu mówi, kiedy. Nic nie jest wysyłane: lista i trasa do internetu pochodzą z tablic, które prowadzi jądro Linuksa, a ich odczyt nie wymaga sudo.\n\nKolumny:\n stan - Działa znaczy, że karta może przesyłać ruch. Nie działa, Brak urządzenia i Nie działa warstwa niżej znaczą, że nie może. Pętla zwrotna, karta dummy i tunel WireGuard działają, gdy są włączone i mają łącze: ich sterowniki nie podają stanu, a ip pokazuje je jako UNKNOWN.\n typ - Ethernet, Wi-Fi, Pętla zwrotna (komputer rozmawia sam ze sobą), Tunel (często VPN), Wirtualna (most, para veth, sieć kontenera).\n internet - ruch do internetu wychodzi przez tę kartę, dla IPv4, IPv6 albo obu.\n\nWybierz kartę, żeby zobaczyć poniżej jej szczegóły: każdy adres z prefiksem, bramę, serwery DNS, DHCP, adres MAC, MTU i szybkość łącza. Uwaga przy adresie mówi, skąd pochodzi, i ostrzega, gdy inne urządzenie w sieci używa tego samego adresu. „Z dzierżawą” to adres IPv6 nadany na ograniczony czas: jądro nie mówi, czy dał go DHCPv6, czy router. DHCP ma wartość Tak, gdy adres IPv4 ma dzierżawę albo trasa domyślna przyszła z DHCP. Metryka to metryka trasy domyślnej.\n\nKażdy serwer DNS jest pokazany przy karcie, przez którą wychodzą do niego pytania. Gdy za ten komputer odpowiada systemd-resolved, adres 127.0.0.53 jest przy pętli zwrotnej: on pyta serwery pokazane przy innych kartach.\n\nKliknij prawym przyciskiem bramę albo serwer DNS, żeby ograniczyć zakłócenia do tego adresu. To tylko wypełnia pole IP w sekcji Celuj w adres docelowy na karcie Sterowanie: nic się nie zmieni, dopóki nie naciśniesz START albo Zastosuj zmiany.\n\nPierwszy wiersz, Proxy, pokazuje, czy programy na tym komputerze łączą się przez serwer proxy. Wybierz go, żeby zobaczyć ustawienia proxy pulpitu (Ustawienia GNOME, za którymi idą Firefox, Chrome i programy GNOME) oraz zmienne proxy z /etc/environment i folderów environment.d (wszystkie konta) oraz z ~/.config/environment.d (Twoje). Zmienne ustawione w plikach startowych powłoki, takich jak ~/.bashrc, nie są czytane. W Linuksie wielkość liter w nazwie zmiennej ma znaczenie, a curl czyta tylko http_proxy pisane małymi literami. Program, który używa proxy, łączy się z serwerem proxy, a nie z Twoim celem: żeby psuć jego ruch, kliknij prawym przyciskiem serwer proxy i ogranicz zakłócenia do jego adresu. Serwer podany nazwą nie jest sprawdzany, a narzędzie nie szuka skryptu, który znalazłoby automatyczne wykrywanie - nic nie jest wysyłane. Hasła w adresach są ukryte. Ustawienia pulpitu są czytane przez gsettings, jako Ty, także gdy program działa pod sudo.\n\n„Kopiuj raport” kopiuje wszystkie karty i ustawienia proxy, po angielsku. Tekst zawiera adresy i adresy MAC tego komputera - przeczytaj go, zanim wkleisz go gdziekolwiek publicznie. Kliknij prawym przyciskiem kartę na liście, żeby skopiować tylko ją, w tej samej postaci.", "tools.adapters.help_title": "Karty sieciowe", "tools.adapters.item.addresses": "Adresy", "tools.adapters.item.description": "Urządzenie", @@ -789,12 +794,14 @@ "tools.adapters.log_limited": "Adres docelowy ustawiony z narzędzia Karty sieciowe", "tools.adapters.note_stale": "Wiersze poniżej odczytano o {time}.", "tools.adapters.origin.dhcp": "DHCP", + "tools.adapters.origin.lease": "z dzierżawą (DHCPv6 albo router)", "tools.adapters.origin.manual": "ustawiony ręcznie", "tools.adapters.origin.other": "inne źródło", "tools.adapters.origin.router": "ogłoszenie routera", "tools.adapters.origin.well_known": "nadany przez system", "tools.adapters.proxy.item.account": "Proxy tego konta", "tools.adapters.proxy.item.bypass": "Bez serwera proxy", + "tools.adapters.proxy.item.desktop": "Proxy pulpitu (GNOME)", "tools.adapters.proxy.item.detect": "Automatycznie wykryj ustawienia", "tools.adapters.proxy.item.script": "Skrypt instalacyjny", "tools.adapters.proxy.item.server": "Serwer proxy", @@ -802,7 +809,9 @@ "tools.adapters.proxy.item.winhttp": "Proxy usług (WinHTTP)", "tools.adapters.proxy.item.winhttp_bypass": "Bez proxy usług (WinHTTP)", "tools.adapters.proxy.note.all_accounts": "wszystkie konta", + "tools.adapters.proxy.note.curl_lowercase": "curl jej nie czyta, tylko http_proxy", "tools.adapters.proxy.note.detect": "Windows szuka skryptu w sieci", + "tools.adapters.proxy.note.detect_linux": "programy szukają skryptu w sieci", "tools.adapters.proxy.note.goes_here": "programy z proxy łączą się tutaj, nie z celem", "tools.adapters.proxy.note.local": " = nazwy bez kropki", "tools.adapters.proxy.note.masked": "hasło ukryte", @@ -816,6 +825,7 @@ "tools.adapters.proxy.state.in_use": "Używane", "tools.adapters.proxy.state.not_set": "Nieustawione", "tools.adapters.proxy.state.unreadable": "Nie dało się odczytać", + "tools.adapters.proxy.value.not_here": "brak na tym systemie", "tools.adapters.refresh": "Odśwież", "tools.adapters.report_logged": "raport kart sieciowych", "tools.adapters.status.code": "Stan {code}", diff --git a/lang/zh.json b/lang/zh.json index 5e2a2a55..1d8fbb0d 100644 --- a/lang/zh.json +++ b/lang/zh.json @@ -664,6 +664,7 @@ "tips.tools_adapters_col_item": "此行的内容。", "tips.tools_adapters_col_kind": "网卡种类:以太网、Wi-Fi、隧道(通常是 VPN)或虚拟网卡。", "tips.tools_adapters_col_name": "网卡名称,与 Windows 设置中显示的相同。", + "tips.tools_adapters_col_name_linux": "网卡名称,与 ip link 显示的相同。", "tips.tools_adapters_col_note": "地址的来源。如果另一台设备使用相同地址,此处会给出警告。", "tips.tools_adapters_col_status": "“正常”表示网卡可以传输流量。点击 ? 查看每种状态的含义。", "tips.tools_adapters_col_value": "系统给出的值。右键单击网关或 DNS 服务器,可将干扰仅限于该地址。", @@ -675,8 +676,9 @@ "tips.tools_diagnostics_clean": "卸载遗留在加载状态的 WinDivert 驱动。会话进行中不可用。", "tips.tools_diagnostics_copy": "复制版本和所有检查结果(英文),可直接粘贴到错误报告的 Environment report 字段。", "tips.tools_diagnostics_help": "每项检查的含义以及处理方法", - "tips.tools_diagnostics_not_here": "此系统暂不可用。报告的这一部分来自“网卡”工具,该工具目前仅适用于 Windows。", + "tips.tools_diagnostics_not_here": "此系统暂不可用。报告的这一部分来自“网卡”工具,该工具目前在此系统上还不能使用。", "tips.tools_diagnostics_proxy": "在复制的报告中加入代理设置:此帐户的、服务的 (WinHTTP) 以及代理变量,密码已隐藏。勾选之前处于关闭状态 - 粘贴到公开的错误报告之前,请先检查文本。", + "tips.tools_diagnostics_proxy_linux": "在复制的报告中加入代理设置:桌面的 (GNOME) 以及代理变量,密码已隐藏。勾选之前处于关闭状态 - 粘贴到公开的错误报告之前,请先检查文本。", "tips.tools_exprtest_expression": "按“控制”页的写法输入表达式。逗号分隔各项,短横线表示范围,“!”表示排除。点击“?”查看示例。", "tips.tools_exprtest_field": "要测试其表达式的“控制”页字段。它决定测试值的类型:地址、端口或进程。", "tips.tools_exprtest_help": "表达式测试的用法和示例", @@ -748,11 +750,14 @@ "tools.adapters.dad.invalid": "不可用", "tools.adapters.dad.tentative": "尚未确认", "tools.adapters.dns_placeholder": "Windows 占位地址,并非为此网卡设置的服务器", + "tools.adapters.dns_stub": "本机上的 systemd-resolved:它会向其他网卡下列出的服务器查询", "tools.adapters.empty": "系统没有列出任何网卡。", "tools.adapters.empty_details": "在上方选择一个网卡以查看其详细信息。", "tools.adapters.empty_failed": "无法读取网卡列表。上面一行说明了原因。按“刷新”重试。", + "tools.adapters.error_changing": "网卡列表在读取期间不断变化。请稍后按“刷新”。", "tools.adapters.error_not_ready": "某个网卡仍在获取地址(DHCP),因此系统没有列出网卡。请稍后按“刷新”。", "tools.adapters.help_body": "本机的每个网卡,以及 ipconfig /all 为其列出的信息。互联网所经由的网卡排在最上面,其后是正常工作的网卡。\n\n打开此选项卡时以及按下“刷新”时会读取列表。状态行会显示读取时间。不发送任何数据:列表和通往互联网的路由均来自 Windows 维护的表。\n\n列:\n 状态 - “正常”表示网卡可以传输流量。“断开”、“设备不存在”和“下层断开”表示不能。\n 类型 - 以太网、Wi-Fi、环回(本机与自身通信)、隧道(通常是 VPN)、虚拟(Hyper-V、WSL、VPN 客户端)。\n 互联网 - 发往互联网的流量经由此网卡发出(IPv4、IPv6 或两者)。\n\n选择一个网卡即可在下方查看其详细信息:每个地址及其前缀、网关、DNS 服务器、DHCP、MAC 地址、MTU 和链路速度。地址旁的备注说明其来源,并在网络中另一台设备使用相同地址时发出警告。\n\n没有自己 DNS 服务器的网卡会列出三个以 fec0: 开头的地址。这是 Windows 填入的占位地址,ipconfig 不会显示它们。\n\n右键单击网关或 DNS 服务器,可将干扰仅限于该地址。这只会填写“控制”页“目标地址筛选”中的 IP 字段:在按下“开始”或“应用更改”之前,不会有任何变化。\n\n第一行“代理”显示本机上的程序是否通过代理连接。选择它可查看此帐户的设置(自动检测、设置脚本、代理服务器以及绕过代理的名称)、Windows 服务使用的代理 (WinHTTP),以及 curl、git、Python、Node 和 Go 读取的代理变量。使用代理的程序连接的是代理,而不是你的目标:要干扰其流量,请右键单击代理服务器,将干扰仅限于其地址。以名称给出的服务器不会被查询,本工具也不会查找自动检测可能找到的脚本 - 不发送任何数据。地址中的密码已隐藏。\n\n“复制报告”会复制所有网卡和代理设置(英文)。文本包含本机的地址和 MAC 地址 - 粘贴到任何公开位置之前,请先阅读。在列表中右键单击一个网卡,可以只复制该网卡的信息,格式相同。", + "tools.adapters.help_linux": "本机的每个网卡,以及 ip addr 和 ip route 为其显示的信息。互联网所经由的网卡排在最上面,其后是正常工作的网卡。\n\n打开此选项卡时以及按下“刷新”时会读取列表。状态行会显示读取时间。不发送任何数据:列表和通往互联网的路由均来自 Linux 内核维护的表,读取它们不需要 sudo。\n\n列:\n 状态 - “正常”表示网卡可以传输流量。“断开”、“设备不存在”和“下层断开”表示不能。环回、dummy 网卡和 WireGuard 隧道在已启用且有链路时为“正常”:它们的驱动不报告状态,ip 将其显示为 UNKNOWN。\n 类型 - 以太网、Wi-Fi、环回(本机与自身通信)、隧道(通常是 VPN)、虚拟(网桥、veth 对、容器网络)。\n 互联网 - 发往互联网的流量经由此网卡发出(IPv4、IPv6 或两者)。\n\n选择一个网卡即可在下方查看其详细信息:每个地址及其前缀、网关、DNS 服务器、DHCP、MAC 地址、MTU 和链路速度。地址旁的备注说明其来源,并在网络中另一台设备使用相同地址时发出警告。“有租期”是指限时分配的 IPv6 地址:内核不说明它来自 DHCPv6 还是路由器。当某个 IPv4 地址有租期或默认路由来自 DHCP 时,DHCP 为“是”。跃点数是默认路由的跃点数。\n\n每个 DNS 服务器列在其查询所经由的网卡下。当 systemd-resolved 为本机应答时,127.0.0.53 列在环回下:它会向其他网卡下列出的服务器查询。\n\n右键单击网关或 DNS 服务器,可将干扰仅限于该地址。这只会填写“控制”页“目标地址筛选”中的 IP 字段:在按下“开始”或“应用更改”之前,不会有任何变化。\n\n第一行“代理”显示本机上的程序是否通过代理连接。选择它可查看桌面的代理设置(GNOME 设置,Firefox、Chrome 和 GNOME 程序会遵循它)以及 /etc/environment 和 environment.d 文件夹(所有帐户)和 ~/.config/environment.d(你的帐户)中的代理变量。在 shell 启动文件(如 ~/.bashrc)中设置的变量不会被读取。在 Linux 上变量名区分大小写,curl 只读取小写的 http_proxy。使用代理的程序连接的是代理,而不是你的目标:要干扰其流量,请右键单击代理服务器,将干扰仅限于其地址。以名称给出的服务器不会被查询,本工具也不会查找自动检测可能找到的脚本 - 不发送任何数据。地址中的密码已隐藏。桌面设置通过 gsettings 以你的身份读取,即使程序在 sudo 下运行也是如此。\n\n“复制报告”会复制所有网卡和代理设置(英文)。文本包含本机的地址和 MAC 地址 - 粘贴到任何公开位置之前,请先阅读。在列表中右键单击一个网卡,可以只复制该网卡的信息,格式相同。", "tools.adapters.help_title": "网卡", "tools.adapters.item.addresses": "地址", "tools.adapters.item.description": "设备", @@ -789,12 +794,14 @@ "tools.adapters.log_limited": "已从“网卡”工具设置目标 IP", "tools.adapters.note_stale": "下面各行读取于 {time}。", "tools.adapters.origin.dhcp": "DHCP", + "tools.adapters.origin.lease": "有租期(DHCPv6 或路由器)", "tools.adapters.origin.manual": "手动设置", "tools.adapters.origin.other": "其他来源", "tools.adapters.origin.router": "路由器通告", "tools.adapters.origin.well_known": "系统分配", "tools.adapters.proxy.item.account": "此帐户的代理", "tools.adapters.proxy.item.bypass": "不经过代理", + "tools.adapters.proxy.item.desktop": "桌面的代理 (GNOME)", "tools.adapters.proxy.item.detect": "自动检测设置", "tools.adapters.proxy.item.script": "设置脚本", "tools.adapters.proxy.item.server": "代理服务器", @@ -802,7 +809,9 @@ "tools.adapters.proxy.item.winhttp": "服务代理 (WinHTTP)", "tools.adapters.proxy.item.winhttp_bypass": "不经过服务代理 (WinHTTP)", "tools.adapters.proxy.note.all_accounts": "所有帐户", + "tools.adapters.proxy.note.curl_lowercase": "curl 不读取此变量,只读取 http_proxy", "tools.adapters.proxy.note.detect": "Windows 在网络上查找脚本", + "tools.adapters.proxy.note.detect_linux": "程序在网络上查找脚本", "tools.adapters.proxy.note.goes_here": "使用它的程序连接到这里,而不是目标", "tools.adapters.proxy.note.local": " = 不含点的名称", "tools.adapters.proxy.note.masked": "密码已隐藏", @@ -816,6 +825,7 @@ "tools.adapters.proxy.state.in_use": "使用中", "tools.adapters.proxy.state.not_set": "未设置", "tools.adapters.proxy.state.unreadable": "无法读取", + "tools.adapters.proxy.value.not_here": "此系统上没有", "tools.adapters.refresh": "刷新", "tools.adapters.report_logged": "网卡报告", "tools.adapters.status.code": "状态 {code}", diff --git a/pyproject.toml b/pyproject.toml index cddb867c..1a08c975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,10 @@ max-complexity = 26 # point is to survive exactly that. "beantester/gui/widgets/sortable_tree.py" = ["S112"] "beantester/winenv.py" = ["S112"] +# The one child process the package starts (tests/test_no_telemetry.py, SPAWN_ALLOWED): +# gsettings on Linux, a fixed argv of found programs and constants, never a shell, +# never text from outside. +"beantester/proxy.py" = ["S603"] # Maintainer and CI scripts: they run `git` and `python` by design, and the only # URL opened is a literal api.github.com endpoint whose path is validated first. "tools/**" = ["S603", "S607", "S310"] diff --git a/tests/data/rtnetlink/netns-kernel-7.0.jsonl b/tests/data/rtnetlink/netns-kernel-7.0.jsonl new file mode 100644 index 00000000..507ba459 --- /dev/null +++ b/tests/data/rtnetlink/netns-kernel-7.0.jsonl @@ -0,0 +1,7 @@ +{"label": "links", "request": "2000000012000103010000000000000000000000000000000000000000000000", "answer": ["d0050000100002000100000061da000000000403010000004900010000000000070003006c6f000008000d00e803000005001000000000000500110000000000050043000100000008000400000001000800320000000000080033000000000008001b000000000008001e000000000008003d000000000008001f000100000008002800ffff0000080029000000010008003a000000010008003f0000000100080040000000010008003b00f8ff070008003c00ffff0000080042000000000008002000010000000500210001000000080023000000000008002f000000000008003000000000000600440000000000060045000000000005002700000000000a00010000000000000000000a0002000000000000000000cc0017000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c002b0005000200000000000c0006006e6f71756575650044031a0014002d00080001000100000005000200000000008c00020088000100000000000000000000000000010000000100000001000000010000000200000000000000000000000000000000000000000000000000000001000000010000000000000000000000000000000100000000000000000000000000000000000000000000000000000010270000e80300000000000000000000000000000000000001000000a0020a00080001000000008014000500ffff000055b3ed0015a00000e8030000f400020000000000400000000000010001000000010000000100000001000000ffffffffa00f0000e8030000ffffffff00a30200805101000300000058020000100000000000000001000000010000000100000060ea0000000000000000000000000000000000000000000000000000ffffffff000000000000000010270000e8030000010000000000000000000000010000000000000000000000010000000000000000000000000000000000000080ee360000000000000000000100000000000000000000000000000000000000000000000004000000000000ffff0000ffffffff0100000000000000000000000000000034010300260000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c00060007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400070000000000000000000000000000000000050008000000000024000e00000000000000000000000000000000000000000000000000000000000000000004003e8004004180dc050000100002000100000061da000000000100020000004310010000000000080003006276300008000d00e803000005001000060000000500110000000000050043000000000008000400dc050000080032004400000008003300ffff000008001b000000000008001e000000000008003d000000000008001f000800000008002800ffff0000080029000000010008003a000000010008003f0000000100080040000000010008003b00f8ff070008003c00ffff0000080042000000000008002000080000000500210001000000080023000200000008002f000100000008003000010000000600440000000000060045000000000005002700000000000a000100fe3eb984591e00000a000200ffffffffffff0000cc001700090000000000000009000000000000005e03000000000000c2030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006400070009000000090000005e030000c203000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c002b00050002000000000010001200090001007665746800000000080025000000000008000500020000000c0006006e6f71756575650030031a008c00020088000100000000000000000000000000010000000100000001000000010000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000010270000e80300000000000000000000000000000000000001000000a0020a00080001001000008014000500ffff000058b3ed004e510000e8030000f40002000000000040000000dc05000001000000010000000100000001000000ffffffffa00f0000e80300000000000000a30200805101000300000058020000100000000000000001000000010000000100000060ea000000000000000000000000000000000000000000000000000001000000000000000000000010270000e8030000010000000000000000000000010000000000000000000000010000000000000000000000000000000000000080ee360000000000000000000100000000000000000000000000000000000000000000000004000000000000ffff0000ffffffff010000000000000000000000000000003401030026000000000000000900000000000000e002000000000000010000000000000009000000000000000000000000000000000000000000000000000000000000000900000000000000090000000000000044030000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000900000000000000000000000000000000000000000000009402000000000000440300000000000000000000000000000000000000000000000000000000000000000000000000003c00060007000000000000000100000000000000000000000000000009000000000000000000000000000000000000000000000000000000000000001400070000000000000000000000000000000000050008000000000024000e00000000000000000000000000000000000000000000000000000000000000000004003e8004004180", "cc050000100002000100000061da00000000010003000000c30001000000000008000300646d300008000d00e803000005001000000000000500110000000000050043000000000008000400dc0500000800320000000000080033000000000008001b000000000008001e000000000008003d000000000008001f000100000008002800ffff0000080029000000010008003a000000010008003f0000000100080040000000010008003b000000010008003c00ffff0000080042000000000008002000010000000500210001000000080023000000000008002f000000000008003000000000000600440000000000060045000000000005002700000000000a0001001ec19fa5fda500000a000200ffffffffffff0000cc0017000000000000000000010000000000000000000000000000004600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640007000000000001000000000000004600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c002b000500020000000000100012000a00010064756d6d790000000c0006006e6f71756575650030031a008c00020088000100000000000000000000000000010000000100000001000000010000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000010270000e80300000000000000000000000000000000000001000000a0020a00080001001000008014000500ffff00005eb3ed0000a80000e8030000f40002000000000040000000dc05000001000000010000000100000001000000ffffffffa00f0000e80300000000000000a30200805101000300000058020000100000000000000001000000010000000100000060ea0000000000000000000000000000000000000000000000000000ffffffff000000000000000010270000e8030000010000000000000000000000010000000000000000000000010000000000000000000000000000000000000080ee360000000000000000000100000000000000000000000000000000000000000000000004000000000000ffff0000ffffffff0100000000000000000000000000000034010300260000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000010000000000000038000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000380000000000000000000000000000000000000000000000000000000000000000000000000000003c00060007000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000001400070000000000000000000000000000000000050008000000000024000e00000000000000000000000000000000000000000000000000000000000000000004003e800400418078070000100002000100000061da000000000100040000000210000000000000080003006272300008000d00e803000005001000020000000500110000000000050043000100000008000400dc050000080032004400000008003300ffff000008001b000000000008001e000000000008003d000000000008001f000100000008002800ffff0000080029000000010008003a000000010008003f0000000100080040000000010008003b000000010008003c00ffff0000080042000000000008002000010000000500210000000000080023000100000008002f000000000008003000010000000600440000000000060045000000000005002700000000000a0001009ad695f68b2800000a000200ffffffffffff0000cc0017000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c002b000500020000000000bc0112000b0001006272696467650000ac0102000c00100000000000000000000c00110000000000000000000c00120000000000000000000c001300000000000000000008000100dc05000008000200c800000008000300d0070000080004003075000008000500000000000600060000800000050007000000000006000900000000000c000b0080009ad695f68b280c000a0080009ad695f68b2806000c000000000008000d000000000005000e000000000005000f00000000000a0014000180c200000000000c002e00000000001f0000000800300000000000080031000000000006000800810000000600270001000000050029000000000005002d0000000000050016000100000005001700010000000500180000000000050019000000000005002a000000000008001a001000000008001b000010000008001c000200000008001d000200000005002b000200000005002c00010000000c001e0064000000000000000c001f0090650000000000000c0020009c630000000000000c002100d4300000000000000c002200e8030000000000000c002300350c000000000000050024000000000005002500000000000500260000000000090006006e6f6f700000000030031a008c00020088000100000000000000000000000000010000000100000001000000010000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000010270000e80300000000000000000000000000000000000001000000a0020a00080001000000000014000500ffff000072b3ed0083a40000e8030000f40002000000000040000000dc05000001000000010000000100000001000000ffffffffa00f0000e80300000000000000a30200805101000300000058020000100000000000000001000000010000000100000060ea000000000000000000000000000000000000000000000000000001000000000000000000000010270000e8030000010000000000000000000000010000000000000000000000010000000000000000000000000000000000000080ee360000000000000000000100000000000000000000000000000000000000000000000004000000000000ffff0000ffffffff0100000000000000000000000000000034010300260000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c00060007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400070000000000000000000000000000000000050008000000000024000e00000000000000000000000000000000000000000000000000000000000000000004003e800400418030070000100002000100000061da00000000010005000000820000000000000008000300646d310008000d00e803000005001000020000000500110000000000050043000000000008000400dc0500000800320000000000080033000000000008001b000000000008001e000100000008003d000100000008001f000100000008002800ffff0000080029000000010008003a000000010008003f0000000100080040000000010008003b000000010008003c00ffff00000800420000000000080020000100000008000a00040000000500210001000000080023000000000008002f000000000008003000000000000600440000000000060045000000000005002700000000000a0001009ad695f68b2800000a000200ffffffffffff0000cc0017000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c002b0005000200000000006c0112000a00010064756d6d790000000b000400627269646765000050010500050001000000000006000200200000000800030064000000050004000000000005000500000000000500060000000000050007000000000005001c00000000000500080001000000050009000100000005001b000100000005001e000100000005000a000000000005000c00000000000c000d0080009ad695f68b280c000e0080009ad695f68b2806000f00018000000600100000000000060011000180000006001200010000000500130000000000050014000000000005001d000000000006001f000000000005002000000000000500230000000000050024000000000005002100000000000500270000000000050028000000000005002b00000000000c00150000000000000000000c00160000000000000000000c0017000000000000000000050019000100000008002500000200000800260000000000080029000000000008002a0000000000090006006e6f6f700000000030031a008c00020088000100000000000000000000000000010000000100000001000000010000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000010270000e80300000000000000000000000000000000000001000000a0020a00080001000000000014000500ffff000074b3ed0004ab0000e8030000f40002000000000040000000dc05000001000000010000000100000001000000ffffffffa00f0000e80300000000000000a30200805101000300000058020000100000000000000001000000010000000100000060ea0000000000000000000000000000000000000000000000000000ffffffff000000000000000010270000e8030000010000000000000000000000010000000000000000000000010000000000000000000000000000000000000080ee360000000000000000000100000000000000000000000000000000000000000000000004000000000000ffff0000ffffffff0100000000000000000000000000000034010300260000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c00060007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400070000000000000000000000000000000000050008000000000024000e00000000000000000000000000000000000000000000000000000000000000000004003e8004004180f8050000100002000100000061da0000000001000600000082000000000000000a000300646d302e3700000008000d00e803000005001000020000000500110000000000050043000000000008000400dc050000080032000000000008003300ffff000008001b000000000008001e000000000008003d000000000008001f000100000008002800ffff0000080029000000010008003a000000010008003f0000000100080040000000010008003b000000010008003c00ffff0000080042000000000008002000010000000500210001000000080023000000000008002f000000000008003000000000000600440000000000060045000000000005002700000000000a0001001ec19fa5fda500000a000200ffffffffffff0000cc0017000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c002b0005000200000000003000120009000100766c616e0000000020000200060005008100000006000100070000000c00020001000000ffffffff0800050003000000090006006e6f6f700000000030031a008c00020088000100000000000000000000000000010000000100000001000000010000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000010270000e80300000000000000000000000000000000000001000000a0020a00080001000000000014000500ffff00007cb3ed00e2930000e8030000f40002000000000040000000dc05000001000000010000000100000001000000ffffffffa00f0000e80300000000000000a30200805101000300000058020000100000000000000001000000010000000100000060ea0000000000000000000000000000000000000000000000000000ffffffff000000000000000010270000e8030000010000000000000000000000010000000000000000000000010000000000000000000000000000000000000080ee360000000000000000000100000000000000000000000000000000000000000000000004000000000000ffff0000ffffffff0100000000000000000000000000000034010300260000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c00060007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400070000000000000000000000000000000000050008000000000024000e00000000000000000000000000000000000000000000000000000000000000000004003e8004004180f0050000100002000100000061da00000000feff07000000901000000000000008000300746e300008000d00f401000005001000020000000500110000000000050043000000000008000400dc050000080032004400000008003300ffff000008001b000000000008001e000000000008003d000000000008001f000100000008002800ffff0000080029000000010008003a000000010008003f0000000100080040000000010008003b000000010008003c00ffff0000080042000000000008002000010000000500210000000000080023000100000008002f00000000000800300001000000060044000000000006004500000000000500270000000000cc0017000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c002b000500020000000000380012000800010074756e002c00020005000300010000000500040000000000050005000000000005000600010000000500070000000000090006006e6f6f700000000044031a0014002d00080001000100000005000200000000008c00020088000100000000000000000000000000010000000100000001000000010000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000010270000e80300000000000000000000000000000000000001000000a0020a00080001000000000014000500ffff00007cb3ed0023ae0000e8030000f40002000000000040000000dc05000001000000010000000100000001000000ffffffffa00f0000e8030000ffffffff00a30200805101000300000058020000100000000000000001000000010000000100000060ea0000000000000000000000000000000000000000000000000000ffffffff000000000000000010270000e8030000010000000000000000000000010000000000000000000000010000000000000000000000000000000000000080ee360000000000000000000100000000000000000000000000000000000000000000000004000000000000ffff0000ffffffff0100000000000000000000000000000034010300260000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c00060007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400070000000000000000000000000000000000050008000000000024000e00000000000000000000000000000000000000000000000000000000000000000004003e8004004180cc050000100002000100000061da00000000feff08000000d100010000000000080003007767300008000d00e8030000050010000000000005001100000000000500430000000000080004008c050000080032000000000008003300a0ffff7f08001b000000000008001e000000000008003d000000000008001f000100000008002800ffff0000080029000000010008003a000000010008003f0000000100080040000000010008003b00f8ff070008003c00ffff0000080042000000000008002000010000000500210001000000080023000000000008002f00000000000800300000000000060044008000000006004500200000000500270000000000cc0017000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c002b000500020000000000140012000e0001007769726567756172640000000c0006006e6f71756575650044031a0014002d00080001000100000005000200000000008c00020088000100000000000000000000000000010000000100000000000000010000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000010270000e80300000000000000000000000000000000000001000000a0020a00080001000000008014000500ffff00008cb3ed002f9d0000e8030000f400020000000000400000008c05000001000000010000000100000001000000ffffffffa00f0000e8030000ffffffff00a30200805101000300000058020000100000000000000001000000010000000100000060ea0000000000000000000000000000000000000000000000000000ffffffff000000000000000010270000e8030000010000000000000000000000010000000000000000000000010000000000000000000000000000000000000080ee360000000000000000000100000001000000000000000000000000000000000000000004000000000000ffff0000ffffffff0100000000000000000000000000000034010300260000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c00060007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001400070000000000000000000000000000000000050008000100000024000e00000000000000000000000000000000000000000000000000000000000000000004003e8004004180", "14000000030002000100000061da000000000000"]} +{"label": "addresses", "request": "180000001600010301000000000000000000000000000000", "answer": ["4c000000140002000100000061da0000020880fe01000000080001007f000001080002007f000001070003006c6f0000080008008000000014000600ffffffffffffffff55b3ed0055b3ed004c000000140002000100000061da0000021880000200000008000100c633640208000200c63364020800030062763000080008008000000014000600ffffffffffffffff58b3ed0058b3ed004c000000140002000100000061da0000021801000200000008000100c633640308000200c63364030800030062763000080008000100000014000600610000002901000058b3ed0058b3ed004c000000140002000100000061da0000021821000200000008000100c633640408000200c6336404080003006276300008000800210000001400060000000000290100005ab3ed005ab3ed004c000000140002000100000061da0000021880000300000008000100cb00710108000200cb00710108000300646d3000080008008000000014000600ffffffffffffffff5fb3ed005fb3ed004c000000140002000100000061da00000220800008000000080001000a420002080002000a4200020800030077673000080008008000000014000600ffffffffffffffff8db3ed008db3ed0050000000140002000100000061da00000a8080fe01000000140001000000000000000000000000000000000114000600ffffffffffffffff55b3ed0055b3ed00080008008000000005000b000100000048000000140002000100000061da00000a408200020000001400010020010db800010000000000000000000214000600ffffffffffffffff5cb3ed005cb3ed00080008008200000048000000140002000100000061da00000a40c800020000001400010020010db800010000000000000000000514000600ffffffffffffffff5cb3ed005cb3ed0008000800c800000050000000140002000100000061da00000a4080fd0200000014000100fe80000000000000fc3eb9fffe84591e14000600ffffffffffffffff58b3ed0058b3ed00080008008000000005000b000300000048000000140002000100000061da00000a408000030000001400010020010db800030000000000000000000914000600ffffffffffffffff8bb4ed008bb4ed00080008008000000048000000140002000100000061da00000a408000030000001400010020010db800020000000000000000000114000600ffffffffffffffff5fb3ed005fb3ed00080008008000000050000000140002000100000061da00000a4080fd0300000014000100fe800000000000001cc19ffffea5fda514000600ffffffffffffffff5eb3ed005eb3ed00080008008000000005000b0003000000", "14000000030002000100000061da000000000000"]} +{"label": "routes2", "request": "1c0000001a0001030100000000000000020000000000000000000000", "answer": ["3c000000180002000100000061da000002000000fe1000010000000008000f00fe000000080006006400000008000500c633640108000400020000003c000000180002000100000061da000002000000fe0300010400000008000f00fe00000008000600c800000008000500cb0071fe08000400030000003c000000180002000100000061da000002180000fe02fd010000000008000f00fe00000008000100c633640008000700c633640208000400020000003c000000180002000100000061da000002180000fe02fd010000000008000f00fe00000008000100cb00710008000700cb00710108000400030000003c000000180002000100000061da000002200000ff02fe020000000008000f00ff000000080001000a420002080007000a42000208000400080000003c000000180002000100000061da000002080000ff02fe020000000008000f00ff000000080001007f000000080007007f00000108000400010000003c000000180002000100000061da000002200000ff02fe020000000008000f00ff000000080001007f000001080007007f00000108000400010000003c000000180002000100000061da000002200000ff02fd030000000008000f00ff000000080001007fffffff080007007f00000108000400010000003c000000180002000100000061da000002200000ff02fe020000000008000f00ff00000008000100c633640208000700c633640208000400020000003c000000180002000100000061da000002200000ff02fe020000000008000f00ff00000008000100c633640308000700c633640208000400020000003c000000180002000100000061da000002200000ff02fe020000000008000f00ff00000008000100c633640408000700c633640208000400020000003c000000180002000100000061da000002200000ff02fd030000000008000f00ff00000008000100c63364ff08000700c633640208000400020000003c000000180002000100000061da000002200000ff02fe020000000008000f00ff00000008000100cb00710108000700cb00710108000400030000003c000000180002000100000061da000002200000ff02fd030000000008000f00ff00000008000100cb0071ff08000700cb0071010800040003000000", "14000000030002000100000061da000000000000"]} +{"label": "routes10", "request": "1c0000001a00010301000000000000000a0000000000000000000000", "answer": ["74000000180002000100000061da00000a400000fe0200010000000008000f00fe0000001400010020010db80001000000000000000000000800060000010000080004000200000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a400000fe0200010000000008000f00fe0000001400010020010db80002000000000000000000000800060000010000080004000300000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a400000fe0200010000000008000f00fe0000001400010020010db80003000000000000000000000800060000010000080004000300000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a400000fe0200010000000008000f00fe00000014000100fe8000000000000000000000000000000800060000010000080004000200000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a400000fe0200010000000008000f00fe00000014000100fe8000000000000000000000000000000800060000010000080004000300000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a000000fe0300010000000008000f00fe00000008000600000400001400050020010db8000100000000000000000001080004000200000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a800000ff0200020000000008000f00ff00000014000100000000000000000000000000000000010800060000000000080004000100000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a800000ff0200020000000008000f00ff0000001400010020010db80001000000000000000000020800060000000000080004000200000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a800000ff0200020000000008000f00ff0000001400010020010db80002000000000000000000010800060000000000080004000300000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a800000ff0200020000000008000f00ff0000001400010020010db80003000000000000000000090800060000000000080004000300000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a800000ff0200020000000008000f00ff00000014000100fe800000000000001cc19ffffea5fda50800060000000000080004000300000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a800000ff0200020000000008000f00ff00000014000100fe80000000000000fc3eb9fffe84591e0800060000000000080004000200000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a080000ff0200050000000008000f00ff00000014000100ff0000000000000000000000000000000800060000010000080004000200000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a080000ff0200050000000008000f00ff00000014000100ff0000000000000000000000000000000800060000010000080004000300000024000c000000000000000000000000000000000000000000000000000000000000000000050014000000000074000000180002000100000061da00000a080000ff0200050000000008000f00ff00000014000100ff0000000000000000000000000000000800060000010000080004000800000024000c0000000000000000000000000000000000000000000000000000000000000000000500140000000000", "14000000030002000100000061da000000000000"]} +{"label": "get 8.8.8.8", "request": "240000001a00050001000000000000000220000000000000000000000800010008080808", "answer": ["70000000180000000100000061da000002200000fe0000010002000008000f00fe0000000800010008080808080004000200000008000700c633640208000500c6336401080019000000000024000c000200000000000000000000000000000000000000000000000000000000000000", "24000000020000010100000061da000000000000240000001a0005000100000000000000"]} +{"label": "get 2001:4860:4860::8888", "request": "300000001a00050001000000000000000a80000000000000000000001400010020014860486000000000000000008888", "answer": ["b0000000180000000100000061da00000a808000fe0300010000000008000f00fe000000140001002001486048600000000000000000888814000200000000000000000000000000000000001400070020010db800010000000000000000000208000600000400001400050020010db8000100000000000000000001080004000200000024000c0002000000000000000000000000000000000000000000000000000000000000000500140000000000", "24000000020000010100000061da000000000000300000001a0005000100000000000000"]} +{"label": "get 127.0.0.53", "request": "240000001a0005000100000000000000022000000000000000000000080001007f000035", "answer": ["68000000180000000100000061da000002200000fe0000020002008008000f00fe000000080001007f0000350800040001000000080007007f000001080019000000000024000c000200000000000000000000000000000000000000000000000000000000000000", "24000000020000010100000061da000000000000240000001a0005000100000000000000"]} diff --git a/tests/test_adapters_linux.py b/tests/test_adapters_linux.py new file mode 100644 index 00000000..8a961057 --- /dev/null +++ b/tests/test_adapters_linux.py @@ -0,0 +1,261 @@ +"""``adapters._Linux``: the Adapters tool's rows from a Linux kernel's own tables. + +The kernel's answers are a real kernel's (``tests/data/rtnetlink``, see +``test_rtnetlink.py``); sysfs and the resolver's files are written into a temporary +root, so every row is made here on any runner. ``paths.read_small_file``, which reads +those files, is tested at the end. +""" +import os +import struct +import sys + +import pytest + +from beantester import adapters, paths, rtnetlink +from beantester.nettools import adapters as logic +from fakes import check +from test_rtnetlink import BY_LABEL, kernel, route_answer + + +def _write(root, relative, text): + path = os.path.join(root, *relative.split("/")) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(text) + + +def _lookup(ip, oif): + """The request ``route_to(ip)`` sends, and the kernel's answer through ``oif``.""" + sent = [] + rtnetlink.route_to(ip, lambda m: sent.append(m) or [(2, struct.pack("=i", -101))]) + return {sent[0]: route_answer(oif, rtnetlink.LINUX_AF_INET6 if ":" in ip else + rtnetlink.LINUX_AF_INET)} + + +def rows(tmp_path, extra=None): + reading = adapters.read(adapters._Linux(kernel(extra), root=str(tmp_path))) + return {row.name: row for row in reading.adapters}, reading + + +def test_linux_rows_read_as_the_kernel_holds_them(tmp_path): + _write(str(tmp_path), "sys/class/net/bv0/speed", "10000\n") + _write(str(tmp_path), "sys/class/net/br0/speed", "10000\n") # down: not shown + found, reading = rows(tmp_path) + check("every adapter, in the kernel's order", list(found) == + ["lo", "bv0", "dm0", "br0", "dm1", "dm0.7", "tn0", "wg0"], f"({list(found)})") + lo, bv0, dm0, br0, wg0 = (found[n] for n in ("lo", "bv0", "dm0", "br0", "wg0")) + check("the loopback: up, its kind, no MAC (six zeros)", + (lo.status, lo.kind, lo.mac) == (1, 24, "")) + check("UNKNOWN with its link is up (dummy, WireGuard); a bridge with nothing is down", + (dm0.status, wg0.status, br0.status) == (1, 1, 2)) + check("a veth: virtual, its MAC as ip writes it, 10 Gbit/s", + (bv0.kind, bv0.description, bv0.receive_bps, bv0.transmit_bps) + == (53, "veth", 10 ** 10, 10 ** 10) and bv0.mac.count(":") == 5 + and bv0.mac == bv0.mac.lower(), f"({bv0})") + check("no speed for an adapter that is down", br0.receive_bps is None) + check("tun and WireGuard are tunnels without a MAC", + (found["tn0"].kind, wg0.kind, wg0.mac) == (131, 131, "")) + check("the key is the name, never the proxy row's", + [r.key for r in found.values()][:2] == ["if:lo", "if:bv0"]) + check("each adapter's default gateways, IPv4 before IPv6", + (bv0.gateways, dm0.gateways) == (("198.51.100.1", "2001:db8:1::1"), ("203.0.113.254",))) + check("the metric is the default route's", + (bv0.ipv4_metric, bv0.ipv6_metric, dm0.ipv4_metric, dm0.ipv6_metric) + == (100, 1024, 200, None)) + check("DHCP: a lease or a route from DHCP; neither on dm0", (bv0.dhcp, dm0.dhcp) == (True, False)) + check("one index for both versions, 0 without an IPv6 address", + (bv0.if_index, bv0.ipv6_index, br0.ipv6_index) == (2, 2, 0)) + check("what Linux keeps for NetworkManager is not shown", + (bv0.dhcp_server, bv0.dns_suffix) == ("", "")) + check("the route out, for both versions", reading.internet == {4: 2, 6: 2}) + + +def test_linux_says_where_each_address_came_from_and_its_dad_state(tmp_path): + found, _ = rows(tmp_path) + by_ip = {a.ip: a for row in found.values() for a in row.addresses} + check("set by hand (permanent)", by_ip["198.51.100.2"].origin == adapters.ORIGIN_MANUAL) + check("a lease on IPv4 is DHCP's", by_ip["198.51.100.3"].origin == adapters.ORIGIN_DHCP) + check("the loopback and link-local are the system's", + {by_ip[ip].origin for ip in ("127.0.0.1", "::1", "fe80::fc3e:b9ff:fe84:591e")} + == {adapters.ORIGIN_WELL_KNOWN}) + check("deprecated (preferred 0)", by_ip["198.51.100.4"].dad == adapters.DAD_DEPRECATED) + check("DAD failed: a duplicate, not 'tentative'", + by_ip["2001:db8:1::5"].dad == adapters.DAD_DUPLICATE) + check("the rest are in normal use", by_ip["2001:db8:1::2"].dad == adapters.DAD_PREFERRED) + lease = rtnetlink.Address(2, 10, "2001:db8:9::1", 64, 0, 0, 3600, 7200, 0) + check("an IPv6 lease the kernel cannot place: the program's own word", + adapters._linux_origin(lease, adapters.ipaddress.ip_address(lease.ip)) == adapters.LEASE) + ra = lease._replace(proto=adapters.IFA_PROTO_KERNEL_RA) + temporary = lease._replace(flags=adapters.IFA_F_TEMPORARY) + check("the kernel's own SLAAC and a temporary address are the router's", + {adapters._linux_origin(a, adapters.ipaddress.ip_address(a.ip)) for a in (ra, temporary)} + == {adapters.ORIGIN_ROUTER}) + check("a temporary address has a random suffix", adapters._linux_address(temporary).suffix == 5) + secondary = rtnetlink.Address(2, 2, "198.51.100.9", 24, adapters.IFA_F_TEMPORARY | 0x80, + 0, adapters.rtnetlink.FOREVER, adapters.rtnetlink.FOREVER, 0) + check("0x01 on IPv4 is SECONDARY, not temporary", + adapters._linux_address(secondary).suffix == 0) + + +def test_linux_kinds_and_states_from_the_link_layer(): + link = rtnetlink.Link(5, b"x", 1, 0x1 | 0x10000, 0, 1500, b"\x02" * 6, "") + check("Ethernet with Wi-Fi in sysfs is Wi-Fi", adapters._linux_kind(link, True) == 71) + check("plain Ethernet", adapters._linux_kind(link, False) == 6) + check("PPP, a sit tunnel, something else", + [adapters._linux_kind(link._replace(arphrd=a), False) for a in (512, 776, 280)] + == [23, 131, 1]) + check("UNKNOWN switched on without a link: unknown", + adapters._linux_status(link._replace(flags=0x1)) == 4) + check("UNKNOWN with its link but switched off: unknown", + adapters._linux_status(link._replace(flags=0x10000)) == 4) + check("the kernel's states in OperStatus' numbers", + [adapters._linux_status(link._replace(operstate=s, flags=0)) for s in range(7)] + == [4, 6, 2, 7, 3, 5, 1]) + + +def test_linux_lists_each_dns_server_at_the_adapter_it_is_asked_through(tmp_path): + root = str(tmp_path) + _write(root, "etc/resolv.conf", "# by systemd-resolved\nnameserver 127.0.0.53\nsearch .\n") + _write(root, "run/systemd/resolve/resolv.conf", + "nameserver 198.51.100.1\nnameserver 2001:db8:1::1\nnameserver fe80::1%dm0\n") + extra = {**_lookup("198.51.100.1", 2), **_lookup("2001:db8:1::1", 2)} + found, _ = rows(tmp_path, extra) + check("the stub at the loopback, said to be one", + (found["lo"].dns_servers, found["lo"].stub_dns) == (("127.0.0.53",), ("127.0.0.53",))) + check("the servers it asks, at the adapter their route leaves through", + found["bv0"].dns_servers == ("198.51.100.1", "2001:db8:1::1"), f"({found['bv0']})") + check("a zone names its adapter, no lookup asked", found["dm0"].dns_servers == ("fe80::1",)) + details = {d.key: d for d in logic.details(logic.Adapter(found["lo"], 0, ()))} + stub = details["dns|0"] + check("the stub's note, and it is not offered to the Control page", + stub.note == (logic.Words("tools.adapters.dns_stub"),) and stub.address == "") + check("a server it asks is offered", {d.key: d for d in logic.details( + logic.Adapter(found["bv0"], 0, ()))}["dns|0"].address == "198.51.100.1") + check("the report says what the stub is", " DNS: 127.0.0.53 (systemd-resolved, on this " + "computer)" in logic.adapter_lines(logic.Adapter(found["lo"], 0, ()))) + + +def test_linux_reads_resolveds_servers_only_when_programs_ask_its_stub(tmp_path): + root = str(tmp_path) + _write(root, "etc/resolv.conf", "nameserver 198.51.100.1\nnameserver 198.51.100.7\n" + "nameserver 198.51.100.8\nnameserver 198.51.100.9\n") + _write(root, "run/systemd/resolve/resolv.conf", "nameserver 2001:db8:1::1\n") + extra = {**_lookup("198.51.100.1", 2), **_lookup("198.51.100.7", 2), + **_lookup("198.51.100.8", 2)} + found, _ = rows(tmp_path, extra) + check("glibc's three, and not resolved's list", found["bv0"].dns_servers + == ("198.51.100.1", "198.51.100.7", "198.51.100.8"), f"({found['bv0'].dns_servers})") + os.remove(os.path.join(root, "etc", "resolv.conf")) + found, _ = rows(tmp_path) + check("no resolv.conf: no DNS, and the list still reads", + not any(row.dns_servers for row in found.values())) + + +def test_a_resolver_file_it_may_not_read_costs_the_dns_servers_only(tmp_path, monkeypatch): + def refused(path, owner=None, limit=paths.SMALL_FILE): + raise PermissionError(13, "Permission denied", path) + noted = [] + monkeypatch.setattr(paths, "read_small_file", refused) + monkeypatch.setattr(adapters.crashlog, "note", lambda exc, area: noted.append(area)) + found, reading = rows(tmp_path) + check("every adapter still listed, with its addresses", + len(found) == 8 and found["bv0"].addresses and reading.internet == {4: 2, 6: 2}) + check("no DNS server, and the reason kept for the crash log", + not any(r.dns_servers for r in found.values()) and noted == ["adapters.dns"], + f"({noted})") + + +def test_linux_failures_say_which_one(tmp_path): + def changing(message): + raise rtnetlink.Changing(11, "rtnetlink: the lists kept changing while they were read") + with pytest.raises(logic.Unreadable) as raised: + logic.read(adapters._Linux(changing, root=str(tmp_path))) + check("a list that kept changing: Refresh in a moment", + raised.value.user_key == "tools.adapters.error_changing") + + def refused(message): + raise OSError(13, "rtnetlink: the kernel refused the list of adapters") + with pytest.raises(logic.Unreadable) as raised: + logic.read(adapters._Linux(refused, root=str(tmp_path))) + check("any other refusal: the program's own words, no key", raised.value.user_key == "" + and "refused" in str(raised.value)) + + +def test_a_linux_name_that_is_not_utf8_is_shown_and_keyed_apart(tmp_path): + native = adapters._Linux(kernel(), root=str(tmp_path)) + tables = rtnetlink.Tables((rtnetlink.Link(9, b"eth\xff", 1, 0, 2, 1500, b"", ""), + rtnetlink.Link(10, b"eth\xfe", 1, 0, 2, 1500, b"", ""), + rtnetlink.Link(11, b"proxy", 1, 0, 2, 1500, b"", "")), (), ()) + made = [native._row(link, ([], []), (), frozenset()) for link in tables.links] + check("shown with the byte replaced", made[0].name == "eth�") + check("two such names keep two keys", made[0].key != made[1].key, f"({made[0].key})") + check("an adapter called proxy is not the proxy row", made[2].key == "if:proxy") + + +def test_the_linux_words_have_a_text_in_every_language(): + import json + keys = {"tools.adapters.origin.lease", "tools.adapters.dns_stub", + "tools.adapters.error_changing", "tools.adapters.help_linux", + "tips.tools_adapters_col_name_linux"} + check("the lease word is the one the logic gives", logic.ORIGINS[adapters.LEASE] == "lease") + for lang in ("en", "pl", "zh"): + with open(os.path.join(os.path.dirname(__file__), "..", "lang", f"{lang}.json"), + encoding="utf-8") as f: + texts = json.load(f) + missing = sorted(k for k in keys if not texts.get(k)) + check(f"{lang}: every Linux word has a text", not missing, f"({missing})") + + +def test_only_the_sheet_and_two_tips_are_linuxs_own_and_windows_keeps_its_keys(): + from beantester.gui.toolbox import adapters as panel + from beantester.gui.toolbox import diagnostics as diag + check("Windows: today's sheet and tips", (panel.help_key("win32"), panel.tips("win32")) + == ("tools.adapters.help_body", panel.TIPS)) + check("Linux: its own sheet", panel.help_key("linux") == "tools.adapters.help_linux") + linux_tips = panel.tips("linux") + check("Linux: only the name's tip differs", + {k for k in panel.TIPS if panel.TIPS[k] != linux_tips[k]} == {"name"} + and linux_tips["name"] == "tips.tools_adapters_col_name_linux") + proxy_box = next(b for b in diag.OPTIONAL_BOXES if b.section == "proxy") + adapters_box = next(b for b in diag.OPTIONAL_BOXES if b.section == "adapters") + check("the proxy box: Windows' tip on Windows, Linux's on Linux", + (diag.box_tip(proxy_box, "win32"), diag.box_tip(proxy_box, "linux")) + == ("tips.tools_diagnostics_proxy", "tips.tools_diagnostics_proxy_linux")) + check("the adapters box says the same on both", + diag.box_tip(adapters_box, "linux") == adapters_box.tip) + + +# -- paths.read_small_file -------------------------------------------------------- # +def test_a_small_file_is_read_whole_up_to_its_bound(tmp_path): + path = os.path.join(str(tmp_path), "f") + with open(path, "wb") as f: + f.write("nameserver 198.51.100.1\nż\n".encode() + b"\xff") + check("read as UTF-8, what does not decode replaced", + paths.read_small_file(path) == "nameserver 198.51.100.1\nż\n�") + check("at most the bound", paths.read_small_file(path, limit=10) == "nameserver") + check("no file: None", paths.read_small_file(os.path.join(str(tmp_path), "none")) is None) + check("a file under a file: None", + paths.read_small_file(os.path.join(path, "under")) is None) + owner = os.stat(path).st_uid + check("its owner's file is read", paths.read_small_file(path, owner=owner) is not None) + check("anyone else's is not", paths.read_small_file(path, owner=owner + 1) is None) + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="FIFOs and links of Linux") +def test_a_fifo_or_a_dangling_link_in_a_files_place_reads_as_none(tmp_path): # pragma: no cover - Linux + fifo = os.path.join(str(tmp_path), "resolv.conf") + os.mkfifo(fifo) + check("a FIFO is refused without waiting for a writer", paths.read_small_file(fifo) is None) + link = os.path.join(str(tmp_path), "dangling") + os.symlink(os.path.join(str(tmp_path), "gone"), link) + check("a dangling link is no file", paths.read_small_file(link) is None) + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="this runner's own kernel") +def test_this_machines_adapters_read_whole(): # pragma: no cover - Linux + reading = adapters.read() + check("at least the loopback", any(row.kind == 24 for row in reading.adapters), + f"({[row.name for row in reading.adapters]})") + check("every key once", len({row.key for row in reading.adapters}) == len(reading.adapters)) + check("labels the logic can rank", logic.rank(reading) is not None) + check("the recorded fixture is the one the module docstring names", "links" in BY_LABEL) diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index fd93461b..5d0ba1fc 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -123,14 +123,15 @@ def test_windows_offers_every_capability_and_linux_ping_when_it_may_send( 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 Reset when its kernel destroys sockets (``tcpreset._Linux.can_destroy``, stood - in for the same reason), and none of the rest yet.""" + in for the same reason). Adapters and Proxy always (rtnetlink and files, nothing to + be refused); TCP health not 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) or (name == "tcp_reset" and destroys) - for name in capabilities.CAPABILITIES} + want = {name: windows or name in ("adapters", "proxy") 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})") @@ -144,7 +145,7 @@ def test_a_linux_without_the_right_to_ping_says_how_to_get_it(monkeypatch): check("Linux: Ping's own words", capabilities.why_not("ping") == icmp.NOT_HERE_LINUX, f"({capabilities.why_not('ping')})") check("the others keep the shared ones", - capabilities.why_not("adapters") == capabilities.NOT_HERE) + capabilities.why_not("tcp_health") == capabilities.NOT_HERE) monkeypatch.setattr(paths, "running_as_root", lambda: True) check("as root: the words for root", capabilities.why_not("ping") == icmp.NOT_HERE_ROOT, f"({capabilities.why_not('ping')})") diff --git a/tests/test_ci_linux_capture_smoke.py b/tests/test_ci_linux_capture_smoke.py index ebf6e19f..d9f81062 100644 --- a/tests/test_ci_linux_capture_smoke.py +++ b/tests/test_ci_linux_capture_smoke.py @@ -104,6 +104,105 @@ def test_every_broken_reset_is_named(): f"({failures})") +def _adapters_seen(): + """What the Adapters phase reads in a namespace that came out right.""" + def link(index, name, state, flags, kind="ether", mac="02:00:00:00:00:0a", mtu=1500): + return {"ifindex": index, "ifname": name, "operstate": state, "flags": flags, + "link_type": kind, "address": mac, "mtu": mtu} + addr = [{"ifname": "lo", "addr_info": [{"local": "127.0.0.1", "prefixlen": 8}]}, + {"ifname": "bntci-d", "addr_info": [ + {"local": smoke.LEASED, "prefixlen": 24, "dynamic": True}, + {"local": smoke.DEPRECATED, "prefixlen": 24, "dynamic": True, + "deprecated": True}]}, + {"ifname": "bntci-b", "addr_info": [ + {"local": smoke.DUPLICATE6, "prefixlen": 64, "tentative": True, + "dadfailed": True}]}, + # the end that answers for it: in use there (measured on the VM) + {"ifname": "bntci-a", "addr_info": [{"local": smoke.DUPLICATE6, "prefixlen": 64}]}] + return { + "product": [ + {"name": "lo", "index": 1, "status": 1, "mtu": 65536, "mac": "", + "addresses": [["127.0.0.1", 8, smoke.ORIGIN_WELL_KNOWN, smoke.DAD_PREFERRED]]}, + {"name": "bntci-d", "index": 2, "status": 1, "mtu": 1500, "mac": "02:00:00:00:00:0a", + "addresses": [[smoke.LEASED, 24, smoke.ORIGIN_DHCP, smoke.DAD_PREFERRED], + [smoke.DEPRECATED, 24, smoke.ORIGIN_DHCP, smoke.DAD_DEPRECATED]]}, + {"name": "bntci-b", "index": 3, "status": 1, "mtu": 1500, "mac": "02:00:00:00:00:0a", + "addresses": [[smoke.DUPLICATE6, 64, 1, smoke.DAD_DUPLICATE]]}, + {"name": "bntci-a", "index": 4, "status": 1, "mtu": 1500, "mac": "02:00:00:00:00:0a", + "addresses": [[smoke.DUPLICATE6, 64, 1, smoke.DAD_PREFERRED]]}], + "internet": 2, + "link": [link(1, "lo", "UNKNOWN", ["LOOPBACK", "UP", "LOWER_UP"], "loopback", + "00:00:00:00:00:00", 65536), + link(2, "bntci-d", "UNKNOWN", ["BROADCAST", "NOARP", "UP", "LOWER_UP"]), + link(3, "bntci-b", "UP", ["BROADCAST", "MULTICAST", "UP", "LOWER_UP"]), + link(4, "bntci-a", "UP", ["BROADCAST", "MULTICAST", "UP", "LOWER_UP"])], + "addr": addr, + "route": [{"dst": "8.8.8.8", "dev": "bntci-d"}], + } + + +def test_every_broken_adapters_reading_is_named(): + from beantester import adapters + check("the phase's numbers are the module's", ( + smoke.ORIGIN_WELL_KNOWN, smoke.ORIGIN_DHCP, smoke.ORIGIN_ROUTER, smoke.ORIGIN_LEASE, + smoke.DAD_TENTATIVE, smoke.DAD_DUPLICATE, smoke.DAD_DEPRECATED, smoke.DAD_PREFERRED, + smoke.STATUS_UP) == (adapters.ORIGIN_WELL_KNOWN, adapters.ORIGIN_DHCP, + adapters.ORIGIN_ROUTER, adapters.LEASE, adapters.DAD_TENTATIVE, + adapters.DAD_DUPLICATE, adapters.DAD_DEPRECATED, + adapters.DAD_PREFERRED, adapters.STATUS_UP)) + seen = _adapters_seen() + check("a reading that matches ip passes", + smoke.judge_adapters(seen, "here", states=True) == [], f"({smoke.judge_adapters(seen, 'here', states=True)})") + + def broken(change): + copy = _adapters_seen() + change(copy) + return smoke.judge_adapters(copy, "here", states=True) + product = lambda s, name: next(a for a in s["product"] if a["name"] == name) # noqa: E731 + cases = { + "an adapter missing": lambda s: s["product"].pop(), + "a wrong MTU": lambda s: product(s, "lo").update(mtu=1500), + "a MAC written another way": lambda s: product(s, "bntci-b").update( + mac="02-00-00-00-00-0A"), + "UNKNOWN with its link read as down": lambda s: product(s, "bntci-d").update(status=4), + "an address missing": lambda s: product(s, "lo").update(addresses=[]), + "a duplicate read as in use": lambda s: product(s, "bntci-b")["addresses"][0].__setitem__( + 3, smoke.DAD_PREFERRED), + "a lease read as set by hand": lambda s: product(s, "bntci-d")["addresses"][0].__setitem__( + 2, 1), + "the wrong way out": lambda s: s.update(internet=3), + "an adapter's index another than ip's": lambda s: product(s, "lo").update(index=7), + "nothing listed": lambda s: s.update(product=[]), + } + for name, change in cases.items(): + failures = broken(change) + check(f"{name}: failed, naming the adapters", any(f.startswith("adapters here") + for f in failures), f"({failures})") + leaky = _adapters_seen() + product(leaky, "bntci-d")["addresses"].pop() + product(leaky, "bntci-b").update(mac="02:00:00:00:00:0b") + product(leaky, "lo")["addresses"][0][3] = smoke.DAD_DEPRECATED + said = smoke.judge_adapters(leaky, "here", private=True) + check("the machine's own adapters: what differs is named, never an address or a MAC", + len(said) == 3 and not any(x in " ".join(said) for x in ( + smoke.DEPRECATED, "127.0.0.1", "02:00:00:00:00:0")), f"({said})") + check("the same failures with the namespace's details", + smoke.DEPRECATED in " ".join(smoke.judge_adapters(leaky, "here"))) + other = _adapters_seen() + product(other, "lo")["addresses"][0][3] = smoke.DAD_DEPRECATED + check("a DAD state ip does not show, on a machine with no states required", + any("DAD state" in f for f in smoke.judge_adapters(other, "here")), + f"({smoke.judge_adapters(other, 'here')})") + calm = _adapters_seen() + product(calm, "bntci-b")["addresses"][0][3] = smoke.DAD_PREFERRED + calm["addr"][2]["addr_info"][0] = {"local": smoke.DUPLICATE6, "prefixlen": 64} + check("without the states the namespace was given, the phase cannot pass", + any("not seen as a duplicate" in f + for f in smoke.judge_adapters(calm, "here", states=True))) + check("on this machine as it is, no state is required", + smoke.judge_adapters(calm, "here") == []) + + 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_layout.py b/tests/test_gui_layout.py index 77e05668..23a7d4de 100644 --- a/tests/test_gui_layout.py +++ b/tests/test_gui_layout.py @@ -597,6 +597,8 @@ def expect(button, title, body, tip): expect(search, "dialogs.conn_search_help_title", "dialogs.conn_search_help", "tips.conn_search_help") tools = app.pages["tools"] + import sys + from beantester.gui.toolbox import adapters as adapters_panel from beantester.gui.toolbox import diagnostics as diagnostics_panel, ping as ping_panel # The sheets composed of paragraphs, one of them about this system. composed = {"diagnostics": diagnostics_panel.help_keys, "ping": ping_panel.help_keys} @@ -606,6 +608,10 @@ def expect(button, title, body, tip): if tool.id in composed: # its sheet still opens with that key assert composed[tool.id]()[0] == body body = composed[tool.id]() + if tool.id == "adapters": # one whole sheet per system: Linux has its own + body = adapters_panel.help_key() + assert body == ("tools.adapters.help_linux" if sys.platform.startswith("linux") + else "tools.adapters.help_body"), body expect(button, f"tools.{tool.id}.help_title", body, f"tips.tools_{tool.id}_help") unplaced = [b for b in helps_in(root) if b not in placed] assert not unplaced, f"{len(unplaced)} '?' button(s) this test cannot place" diff --git a/tests/test_hot_path.py b/tests/test_hot_path.py index 2546e645..98c4ed1f 100644 --- a/tests/test_hot_path.py +++ b/tests/test_hot_path.py @@ -118,6 +118,9 @@ # The Tools tab's adapter list and its route lookup (``adapters._Native``): read on a # click by a worker, never by a packet thread. ADAPTER_METHODS = ("adapters", "best_interface") +# The same list on Linux (``adapters._Linux``): the kernel's tables over rtnetlink, +# and a route lookup - by the same worker, never by a packet thread. +LINUX_ADAPTER_METHODS = ("adapters", "best_interface") # "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",) @@ -153,6 +156,8 @@ def os_calls_recorded(): for name in NATIVE_METHODS} native.update({(adapters._Native, name, "adapters." + name): getattr(adapters._Native, name) for name in ADAPTER_METHODS}) + native.update({(adapters._Linux, name, "adapters_linux." + name): + getattr(adapters._Linux, name) for name in LINUX_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): @@ -313,6 +318,16 @@ def GetBestInterfaceEx(self, _destination, _index): f"({names})") check("and unwrapped after", adapters._Native.best_interface.__name__ == "best_interface") + # And Linux's: a route lookup the kernel answers with "no route". + def no_route(message): + return [(2, bytes([0x9b, 0xff, 0xff, 0xff]) + message[:16])] # NLMSG_ERROR -101 + with os_calls_recorded() as calls: + adapters._Linux(talk=no_route).best_interface(4) + names = [name for name, _ in calls] + check("Linux's route lookup is recorded too", names == ["adapters_linux.best_interface"], + f"({names})") + check("and unwrapped after", adapters._Linux.best_interface.__name__ == "best_interface") + # And the connection reset's: one SetTcpEntry through a fake binding. class Reset: def SetTcpEntry(self, _row): diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index 37095263..9264359d 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -8260,8 +8260,8 @@ { "label": "proxy: a binding that will not load raises into the adapter list", "file": "beantester/proxy.py", - "old": " native, why = _part(_Native, \"proxy\")\n", - "new": " native, why = _Native(), \"\"\n", + "old": " native, why = _part(_Linux if linux else _Native, \"proxy\")\n", + "new": " native, why = (_Linux if linux else _Native)(), \"\"\n", "test": "test_a_binding_that_will_not_load_costs_the_proxy_and_raises_nothing", }, { @@ -8309,14 +8309,14 @@ { "label": "proxy: NO_PROXY is offered as a place to send the impairments", "file": "beantester/nettools/proxy.py", - "old": " address = (address_of(variable.value) if variable.name in PROXY_VARIABLES else \"\")\n", + "old": " address = address_of(variable.value) if _sends(variable) else \"\"\n", "new": " address = address_of(variable.value)\n", "test": "test_the_details_say_what_each_setting_is_and_offer_only_addresses", }, { "label": "proxy: NO_PROXY alone marks the proxy as in use", "file": "beantester/nettools/proxy.py", - "old": " or any(v.name in PROXY_VARIABLES for v in reading.env))\n", + "old": " or any(_sends(v) for v in reading.env))\n", "new": " or bool(reading.env))\n", "test": "test_the_row_says_in_use_not_set_or_could_not_be_read", }, @@ -8680,17 +8680,17 @@ "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", + "label": "netlink: a cut answer is read as the whole list", + "file": "beantester/netlink.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", + "label": "netlink: a message cut off by its datagram is read as a shorter one", + "file": "beantester/netlink.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", }, { @@ -8700,6 +8700,286 @@ "new": " if False:\n", "test": "test_a_list_the_kernel_refuses_is_an_error_for_both_families", }, + { + "label": "netlink: an answer from another port is read as the kernel's", + "file": "beantester/netlink.py", + "old": " if sender[0] != KERNEL:\n", + "new": " if False:\n", + "test": "test_an_answer_that_does_not_add_up_is_an_error_never_a_shorter_list", + }, + { + "label": "netlink: a list the kernel says changed is not marked so", + "file": "beantester/netlink.py", + "old": " replies.interrupted |= bool(message_flags & NLM_F_DUMP_INTR)\n", + "new": " replies.interrupted |= False\n", + "test": "test_an_answer_that_does_not_add_up_is_an_error_never_a_shorter_list", + }, + { + "label": "netlink: an attribute longer than its message is read short", + "file": "beantester/netlink.py", + "old": " if length < _ATTRIBUTE.size or offset + length > len(data):\n", + "new": " if length < _ATTRIBUTE.size:\n", + "test": "test_an_answer_that_does_not_add_up_is_an_error_never_a_shorter_list", + }, + { + "label": "rtnetlink: a route lookup asks for no acknowledgement and waits for ever", + "file": "beantester/rtnetlink.py", + "old": " replies = talk(netlink.message(RTM_GETROUTE, NLM_F_REQUEST | NLM_F_ACK, body))\n", + "new": " replies = talk(netlink.message(RTM_GETROUTE, NLM_F_REQUEST, body))\n", + "test": "test_a_route_lookup_is_what_ip_route_get_asks_and_sends_nothing", + }, + { + "label": "rtnetlink: a list that changed while it was read is kept", + "file": "beantester/rtnetlink.py", + "old": " if not interrupted:\n", + "new": " if True:\n", + "test": "test_a_list_that_changed_while_it_was_read_is_read_again_and_then_refused", + }, + { + "label": "rtnetlink: no route is an error, not 'no adapter'", + "file": "beantester/rtnetlink.py", + "old": " if code in NO_ROUTE:\n", + "new": " if False:\n", + "test": "test_no_route_is_no_adapter_and_any_other_refusal_an_error", + }, + { + "label": "rtnetlink: a kernel without IPv6 cannot list anything", + "file": "beantester/rtnetlink.py", + "old": " if family == LINUX_AF_INET6 and exc.errno == EAFNOSUPPORT:\n", + "new": " if False:\n", + "test": "test_a_kernel_without_ipv6_still_lists_and_a_refused_list_is_an_error", + }, + { + "label": "rtnetlink: a refused IPv6 list reads as no IPv6", + "file": "beantester/rtnetlink.py", + "old": " if family == LINUX_AF_INET6 and exc.errno == EAFNOSUPPORT:\n", + "new": " if family == LINUX_AF_INET6:\n", + "test": "test_a_kernel_without_ipv6_still_lists_and_a_refused_list_is_an_error", + }, + { + "label": "rtnetlink: a route with several ways out keeps one", + "file": "beantester/rtnetlink.py", + "old": " for way, via in (hops or [(oif, gateway)])]\n", + "new": " for way, via in [(oif, gateway)]]\n", + "test": "test_a_route_with_several_ways_out_is_one_row_per_way", + }, + { + "label": "adapters: Linux's UNKNOWN with its link reads as unknown", + "file": "beantester/adapters.py", + "old": " if (link.operstate == IF_OPER_UNKNOWN\n", + "new": " if (False\n", + "test": "test_linux_rows_read_as_the_kernel_holds_them", + }, + { + "label": "adapters: on Linux a duplicate reads as tentative", + "file": "beantester/adapters.py", + "old": " if address.flags & IFA_F_DADFAILED: # it comes with TENTATIVE (measured)\n", + "new": " if False:\n", + "test": "test_linux_says_where_each_address_came_from_and_its_dad_state", + }, + { + "label": "adapters: on Linux a deprecated address reads as in use", + "file": "beantester/adapters.py", + "old": " if address.flags & IFA_F_DEPRECATED or address.preferred == 0:\n", + "new": " if False:\n", + "test": "test_linux_says_where_each_address_came_from_and_its_dad_state", + }, + { + "label": "adapters: an IPv6 lease is guessed to be the router's", + "file": "beantester/adapters.py", + "old": " return ORIGIN_DHCP if ip.version == 4 else LEASE\n", + "new": " return ORIGIN_DHCP if ip.version == 4 else ORIGIN_ROUTER\n", + "test": "test_linux_says_where_each_address_came_from_and_its_dad_state", + }, + { + "label": "adapters: a Linux adapter is keyed by its index", + "file": "beantester/adapters.py", + "old": " \"if:\" + link.name.decode(\"utf-8\", errors=\"backslashreplace\"), link.ifindex,\n", + "new": " \"if:\" + str(link.ifindex), link.ifindex,\n", + "test": "test_linux_rows_read_as_the_kernel_holds_them", + }, + { + "label": "adapters: resolved's servers are read when programs do not ask its stub", + "file": "beantester/adapters.py", + "old": " if stubs:\n", + "new": " if True:\n", + "test": "test_linux_reads_resolveds_servers_only_when_programs_ask_its_stub", + }, + { + "label": "adapters: more nameservers than glibc asks are shown", + "file": "beantester/adapters.py", + "old": " listed = _nameservers(paths.read_small_file(self._path(RESOLV_CONF)), MAX_NAMESERVERS)\n", + "new": " listed = _nameservers(paths.read_small_file(self._path(RESOLV_CONF)), MAX_ENTRIES)\n", + "test": "test_linux_reads_resolveds_servers_only_when_programs_ask_its_stub", + }, + { + "label": "adapters: a link speed is read for an adapter that is down", + "file": "beantester/adapters.py", + "old": " device = self._device(link, status == STATUS_UP)\n", + "new": " device = self._device(link, True)\n", + "test": "test_linux_rows_read_as_the_kernel_holds_them", + }, + { + "label": "nettools adapters: the resolver's stub is offered to the Control page", + "file": "beantester/nettools/adapters.py", + "old": " elif server in row.stub_dns:\n", + "new": " elif False:\n", + "test": "test_linux_lists_each_dns_server_at_the_adapter_it_is_asked_through", + }, + { + "label": "paths: a file someone else owns is read", + "file": "beantester/paths.py", + "old": " if not stat.S_ISREG(status.st_mode) or (owner is not None and status.st_uid != owner):\n", + "new": " if not stat.S_ISREG(status.st_mode):\n", + "test": "test_a_small_file_is_read_whole_up_to_its_bound", + }, + { + "label": "proxy: under sudo the desktop asked is root's", + "file": "beantester/proxy.py", + "old": " if self.uid != self.own_uid:\n", + "new": " if False:\n", + "test": "test_under_sudo_the_desktop_asked_is_the_persons_and_nothing_else_reaches_the_child", + }, + { + "label": "proxy: gsettings gets this process's environment", + "file": "beantester/proxy.py", + "old": " timeout=GSETTINGS_WAIT_S, env=env, check=False)\n", + "new": " timeout=GSETTINGS_WAIT_S, check=False)\n", + "test": "test_the_one_program_started_gets_nothing_of_this_process", + }, + { + "label": "proxy: the desktop proxy's password is read", + "file": "beantester/proxy.py", + "old": " if len(parts) != 3 or parts[1] in GNOME_SECRETS:\n", + "new": " if len(parts) != 3:\n", + "test": "test_the_desktops_manual_proxy_reads_as_the_servers_and_what_goes_around", + }, + { + "label": "proxy: a system without GNOME reads as a failure", + "file": "beantester/proxy.py", + "old": " if \"No such schema\" in said or \"'memory' GSettings backend\" in said:\n", + "new": " if False:\n", + "test": "test_a_system_without_the_desktops_settings_has_no_such_part", + }, + { + "label": "proxy: an unknown desktop proxy mode reads as none", + "file": "beantester/proxy.py", + "old": " raise ValueError(f\"gsettings: a proxy mode this program does not know: {mode!r}\")\n", + "new": " return NOTHING\n", + "test": "test_each_mode_of_the_desktop_reads_as_windows_reads_its_own", + }, + { + "label": "proxy: a person's variable over every account's does not say so", + "file": "beantester/proxy.py", + "old": " replaces=scope == \"user\" and name in machine)\n", + "new": " replaces=False)\n", + "test": "test_the_variables_come_from_every_file_a_new_program_gets_them_from", + }, + { + "label": "proxy: Linux reads WinHTTP's part", + "file": "beantester/proxy.py", + "old": " machine, machine_error = (_part(native.machine, \"proxy.machine\") if has_machine\n", + "new": " machine, machine_error = (_part(native.machine, \"proxy.machine\") if True\n", + "test": "test_a_gsettings_that_fails_costs_only_the_desktops_part", + }, + { + "label": "nettools proxy: a part the system lacks reads as unreadable", + "file": "beantester/nettools/proxy.py", + "old": " or (reading.machine is None and reading.machine_error)\n", + "new": " or (reading.machine is None)\n", + "test": "test_a_gsettings_that_fails_costs_only_the_desktops_part", + }, + { + "label": "nettools proxy: Windows' curl is told it skips HTTP_PROXY", + "file": "beantester/nettools/proxy.py", + "old": " if reading.system == \"linux\" and variable.name == CURL_SKIPS:\n", + "new": " if variable.name == CURL_SKIPS:\n", + "test": "test_the_rows_and_the_report_say_linuxs_places", + }, + { + "label": "nettools proxy: Linux shows a WinHTTP row", + "file": "beantester/nettools/proxy.py", + "old": " if machine is None and not reading.machine_error:\n", + "new": " if False:\n", + "test": "test_the_rows_and_the_report_say_linuxs_places", + }, + { + "label": "nettools proxy: an uppercase variable on Linux sends nowhere", + "file": "beantester/nettools/proxy.py", + "old": " return variable.name.upper() in PROXY_VARIABLES\n", + "new": " return variable.name in PROXY_VARIABLES\n", + "test": "test_the_rows_and_the_report_say_linuxs_places", + }, + { + "label": "adapters panel: Linux is shown Windows' sheet", + "file": "beantester/gui/toolbox/adapters.py", + "old": " return HELP_LINUX if _is_linux(platform) else HELP\n", + "new": " return HELP\n", + "test": "test_only_the_sheet_and_two_tips_are_linuxs_own_and_windows_keeps_its_keys", + }, + { + "label": "diagnostics panel: Linux's proxy box says WinHTTP", + "file": "beantester/gui/toolbox/diagnostics.py", + "old": " return BOX_TIPS_LINUX.get(box.section, box.tip)\n", + "new": " return box.tip\n", + "test": "test_only_the_sheet_and_two_tips_are_linuxs_own_and_windows_keeps_its_keys", + }, + { + "label": "smoke: the Adapters phase does not compare DAD states", + "file": "tools/ci_linux_capture_smoke.py", + "old": " if mine[3] != _ip_dad(theirs):\n", + "new": " if False:\n", + "test": "test_every_broken_adapters_reading_is_named", + }, + { + "label": "smoke: the Adapters phase passes a namespace without its states", + "file": "tools/ci_linux_capture_smoke.py", + "old": " if states and not any(x[0] == ip and x[field] == value for x in mine.values()):\n", + "new": " if False:\n", + "test": "test_every_broken_adapters_reading_is_named", + }, + { + "label": "adapters: a resolver file it may not read costs the whole list", + "file": "beantester/adapters.py", + "old": " except OSError as exc: # a resolver file it may not read costs the DNS servers only\n", + "new": " except ValueError as exc:\n", + "test": "test_a_resolver_file_it_may_not_read_costs_the_dns_servers_only", + }, + { + "label": "proxy: an empty variable reads as a proxy in use", + "file": "beantester/proxy.py", + "old": " if name in values and values[name].value)\n", + "new": " if name in values)\n", + "test": "test_an_empty_variable_unsets_it_and_sends_nobody_anywhere", + }, + { + "label": "proxy: gsettings and setpriv are looked up on this process's PATH", + "file": "beantester/proxy.py", + "old": " return shutil.which(name, path=CHILD_PATH)\n", + "new": " return shutil.which(name)\n", + "test": "test_the_programs_started_are_found_where_the_child_looks_never_on_this_path", + }, + { + "label": "proxy: an empty home is looked up instead", + "file": "beantester/proxy.py", + "old": " self.home = home if home is not None else paths.home_of(self.uid)\n", + "new": " self.home = home or paths.home_of(self.uid)\n", + "test": "test_under_sudo_the_desktop_asked_is_the_persons_and_nothing_else_reaches_the_child", + }, + { + "label": "smoke: the Adapters phase does not compare indexes", + "file": "tools/ci_linux_capture_smoke.py", + "old": " if mine[\"index\"] != theirs.get(\"ifindex\"):\n", + "new": " if False:\n", + "test": "test_every_broken_adapters_reading_is_named", + }, + { + "label": "smoke: the machine's own addresses go into the log", + "file": "tools/ci_linux_capture_smoke.py", + "old": " said = (f\"{len(differ)}, on {sorted({key[0] for key in differ})}\" if private\n", + "new": " said = (str(differ) if private\n", + "test": "test_every_broken_adapters_reading_is_named", + }, { "label": "conns: Linux is told Windows' sentences", "file": "beantester/gui/pages/conns.py", diff --git a/tests/test_nettools_proxy.py b/tests/test_nettools_proxy.py index 079cddb3..e92640c2 100644 --- a/tests/test_nettools_proxy.py +++ b/tests/test_nettools_proxy.py @@ -176,7 +176,7 @@ def test_the_report_hides_passwords_and_says_none_or_why(): bare[-1] == "proxy variables: ALL_PROXY=***@192.0.2.40:1080 (user)", f"({bare})") -def test_the_tool_reads_through_the_binding_and_on_windows_only(): +def test_the_tool_reads_through_the_binding_on_windows_and_linux(): class Native: # what proxy._Native answers, without a system def user(self): return UserProxy(True, "", "", "") @@ -191,8 +191,8 @@ def env(self): UserProxy(True, "", "", ""), "", MachineProxy("", ""), "", (Variable("NO_PROXY", "localhost", "user"),), ""), f"({got})") import sys - check("read on Windows only, like the adapters", - proxy.supported() == sys.platform.startswith("win")) + check("read on Windows and Linux, like the adapters", + proxy.supported() == sys.platform.startswith(("win", "linux"))) def test_every_word_the_row_can_say_is_in_every_language(): diff --git a/tests/test_no_telemetry.py b/tests/test_no_telemetry.py index 49dccdb3..eb2d20b0 100644 --- a/tests/test_no_telemetry.py +++ b/tests/test_no_telemetry.py @@ -118,12 +118,14 @@ "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/netlink.py", "socket"): + "Linux: the netlink socket to this machine's kernel - sock_diag and rtnetlink (below)", ("beantester/gui/links.py", "webbrowser"): "the page buttons (support page, GitHub page), opened on a click", ("beantester/winenv.py", "subprocess"): "list2cmdline, a pure string helper - nothing here spawns anything", + ("beantester/proxy.py", "subprocess"): + "Linux: gsettings, which reads the desktop's proxy settings (below)", } # The exceptions, each with the reason it exists. A call on one of these modules @@ -171,11 +173,13 @@ ("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/netlink.py", "socket", "socket"): + "a netlink socket (AF_NETLINK): it talks to this machine's kernel and to nothing " + "else. sock_diag: the list of TCP sockets, and for tcpreset the one SOCK_DESTROY " + "a reset is - what a destroy makes the kernel send is SOCK_DESTROY's entry in " + "WIRE_FUNCTIONS. rtnetlink: the lists of adapters, addresses and routes, and a " + "route lookup (what `ip route get` asks), all answered from the kernel's tables. " + "Opened per request and closed before it returns", ("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)", @@ -615,11 +619,50 @@ def test_the_wire_allowlist_names_a_place_not_a_function(): check(name, bool(unallowed_wire(wire)) == caught, f"({wire})") -def test_the_shipped_package_never_spawns_a_process(): - """No child process, so no shelling out to curl, bitsadmin or PowerShell.""" - bad = ["%s:%d %s" % (rel, line, detail) - for rel, detail, line in _scan().get("spawn", [])] - check("the shipped package spawns no process", not bad, f"({bad})") +# The one child process the package may start, by its place - the arrangement of +# WIRE_ALLOWED: the call anywhere else is still caught, and an entry naming code that +# is gone fails. 2026-10-27, the owner's decision for the Adapters tool's proxy row on +# Linux: the desktop's proxy settings are read the way GNOME reads them, by gsettings, +# as the person whose desktop it is (as root it reads root's - measured). +SPAWN_ALLOWED = { + ("beantester/proxy.py", "subprocess.run"): + "Linux: gsettings list-recursively org.gnome.system.proxy, started through " + "setpriv as the person who typed sudo; a fixed argv found on PATH, an " + "environment of HOME, PATH and LC_ALL only, no terminal, a 2 s limit. Traced " + "2026-10-27 (strace -f -e connect,socket,execve, Ubuntu 26.04): its only " + "sockets were AF_UNIX ones to the system's account database, no D-Bus, no " + "AF_INET or AF_INET6", +} + + +def unallowed_spawns(found): + return [(rel, detail, line) for rel, detail, line in found + if (rel, detail) not in SPAWN_ALLOWED] + + +def test_the_shipped_package_spawns_only_the_one_named_process(): + """No child process but the one named above, so no shelling out to curl, + bitsadmin or PowerShell.""" + found = _scan().get("spawn", []) + bad = ["%s:%d %s" % (rel, line, detail) for rel, detail, line in unallowed_spawns(found)] + check("the shipped package spawns no process but the named one", not bad, f"({bad})") + seen = {(rel, detail) for rel, detail, _line in found} + stale = sorted(str(key) for key in SPAWN_ALLOWED if key not in seen) + check("every allowed process is still started where it is allowed", not stale, f"({stale})") + + +def test_the_spawn_allowlist_names_a_place_not_a_call(): + """The canary for SPAWN_ALLOWED: the allowed call in any other module is caught, + and so is another way of starting a program in the allowed one.""" + run = "import subprocess\nsubprocess.run(['gsettings'])\n" + popen = "import subprocess\nsubprocess.Popen(['curl', 'x'])\n" + cases = {"the allowed call in its own module": (run, "beantester/proxy.py", False), + "the same call anywhere else": (run, "beantester/adapters.py", True), + "another spawn in the allowed module": (popen, "beantester/proxy.py", True)} + for name, (source, rel, caught) in cases.items(): + found = [(rel, detail, line) for kind, detail, line in findings(source, rel) + if kind == "spawn"] + check(name, bool(unallowed_spawns(found)) == caught, f"({found})") def test_a_url_literal_lives_only_where_the_licence_needs_one(): @@ -866,6 +909,17 @@ def test_the_only_connection_goes_to_the_documented_route_probe(): # read in glibc and the kernel) - nothing goes on the wire. ("beantester/icmpsock.py", "socket", "if_nametoindex"): "a named interface's number, from this machine's own list - see the comment above", + # The Adapters tool's proxy row on Linux asks gsettings for the desktop's proxy + # settings. How it was checked that this sends nothing: strace -f -e + # connect,socket,execve on the very command (setpriv to the person, then gsettings + # list-recursively org.gnome.system.proxy), 2026-10-27 on Ubuntu 26.04 - its only + # sockets were AF_UNIX ones to /run/systemd/userdb (the account database + # --init-groups reads), no D-Bus, no AF_INET or AF_INET6. Reading a GSettings key + # is a file read in dconf; only a write goes to a service. + ("beantester/proxy.py", "subprocess"): + "import for the gsettings run below", + ("beantester/proxy.py", "spawn", "subprocess.run"): + "gsettings reads the desktop's proxy settings, nothing sent - see the comment above", } # The ctypes libraries a Tools-tab file may load while the promise stands: today's diff --git a/tests/test_proxy.py b/tests/test_proxy.py index c7a31033..d37ba050 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -178,16 +178,26 @@ def broken(root, path, names): def test_a_binding_that_will_not_load_costs_the_proxy_and_raises_nothing(monkeypatch): - """The adapter list is read in the same job: the proxy must never cost it.""" + """The adapter list is read in the same job: the proxy must never cost it. Windows' + reader on any runner (the platform stood in: on Linux ``read`` takes ``_Linux``).""" monkeypatch.setattr(crashlog, "note", lambda *a: None) def will_not_load(*_a, **_k): raise OSError("winhttp.dll did not load, on purpose") monkeypatch.setattr(proxy, "_Native", will_not_load) + monkeypatch.setattr(proxy.sys, "platform", "win32") reading = proxy.read() check("three parts, each saying why", reading.user is None and reading.machine is None and all("on purpose" in e for e in (reading.user_error, reading.machine_error, reading.env_error)), f"({reading})") + # Linux's reader the same way: its two parts say why, the part it has not is not a failure. + monkeypatch.setattr(proxy, "_Linux", will_not_load) + monkeypatch.setattr(proxy.sys, "platform", "linux") + reading = proxy.read() + check("Linux: the desktop and the variables say why, no WinHTTP part", + (reading.system, reading.machine, reading.machine_error) == ("linux", None, "") + and "on purpose" in reading.user_error and "on purpose" in reading.env_error, + f"({reading})") def test_a_text_is_read_up_to_its_bound(monkeypatch): @@ -228,7 +238,7 @@ def test_off_windows_nothing_is_loaded(): pytest.skip("the Windows branch is the live test below") check("no library and no registry", (proxy._winhttp(), proxy._kernel32(), proxy._registry_reader()) == (None, None, None)) - check("and nothing is supported", not proxy.supported()) + check("supported on Linux only, of the rest", proxy.supported() == sys.platform.startswith("linux")) @pytest.mark.skipif(not sys.platform.startswith("win"), reason="the proxy settings are Windows'") diff --git a/tests/test_proxy_linux.py b/tests/test_proxy_linux.py new file mode 100644 index 00000000..a062bb77 --- /dev/null +++ b/tests/test_proxy_linux.py @@ -0,0 +1,287 @@ +"""``proxy._Linux``: the desktop's proxy settings and the proxy variables on Linux. + +``gsettings`` is stood in by a recorder that answers with what the real one printed on +Ubuntu 26.04 (GNOME 50, 2026-10-27) - the person's settings set to a manual proxy for +the measurement, and back to "none" after it. The variables' files are written into a +temporary root and home, so every case runs on any runner. +""" +import os +import subprocess +import sys +from types import SimpleNamespace + +import pytest + +from beantester import proxy +from beantester.nettools import proxy as logic +from beantester.nettools.adapters import Words +from fakes import check + +# `gsettings list-recursively org.gnome.system.proxy`, as printed (ftp lines kept). +MANUAL = """org.gnome.system.proxy autoconfig-url '' +org.gnome.system.proxy ignore-hosts ['localhost', '*.corp', "it's"] +org.gnome.system.proxy mode 'manual' +org.gnome.system.proxy use-same-proxy true +org.gnome.system.proxy.ftp host '' +org.gnome.system.proxy.ftp port 0 +org.gnome.system.proxy.http authentication-password 'secret-\\'quoted' +org.gnome.system.proxy.http authentication-user 'someone' +org.gnome.system.proxy.http enabled false +org.gnome.system.proxy.http host '192.0.2.10' +org.gnome.system.proxy.http port 3128 +org.gnome.system.proxy.http use-authentication false +org.gnome.system.proxy.https host '' +org.gnome.system.proxy.https port 0 +org.gnome.system.proxy.socks host 'proxy.example' +org.gnome.system.proxy.socks port 1080 +""" +NONE = MANUAL.replace("mode 'manual'", "mode 'none'") +HOME = "/home/someone" + + +class Gsettings: + """``proxy._run`` standing in for gsettings: records each ``(argv, env)``, answers + with ``out`` / ``err`` / ``code``, or raises ``raises``.""" + + def __init__(self, out=NONE, err="", code=0, raises=None): + self.out, self.err, self.code, self.raises = out, err, code, raises + self.calls = [] + + def __call__(self, argv, env): + self.calls.append((argv, env)) + if self.raises: + raise self.raises + return SimpleNamespace(stdout=self.out.encode(), stderr=self.err.encode(), + returncode=self.code) + + +def which(found=("gsettings", "setpriv")): + return lambda name: f"/usr/bin/{name}" if name in found else None + + +def linux(run=None, found=("gsettings", "setpriv"), person=None, own_uid=1000, root="/", + home=HOME): + return proxy._Linux(run=run or Gsettings(), which=which(found), root=root, person=person, + own_uid=own_uid, home=home) + + +def test_the_desktops_manual_proxy_reads_as_the_servers_and_what_goes_around(): + got = linux(Gsettings(MANUAL)).user() + check("the servers in Learn's grammar, what is set only", + got.server == "http=192.0.2.10:3128;socks=proxy.example:1080", f"({got})") + check("the names that go around it", got.bypass == "localhost;*.corp;it's") + check("nothing automatic", (got.detect, got.script) == (False, "")) + values = proxy.gnome_values(MANUAL) + check("the proxy's account and password are never read", + not {k for _s, k in values} & proxy.GNOME_SECRETS + and "secret" not in repr(values) and "someone" not in repr(values)) + + +def test_each_mode_of_the_desktop_reads_as_windows_reads_its_own(): + check("none: nothing", linux(Gsettings(NONE)).user() == proxy.NOTHING) + auto = MANUAL.replace("mode 'manual'", "mode 'auto'") + check("auto without an address: the network is asked", + linux(Gsettings(auto)).user() == proxy.UserProxy(True, "", "", "")) + script = auto.replace("autoconfig-url ''", "autoconfig-url 'http://192.0.2.5/p.pac'") + check("auto with an address: that script", + linux(Gsettings(script)).user() == proxy.UserProxy(False, "http://192.0.2.5/p.pac", + "", "")) + v6 = MANUAL.replace("host '192.0.2.10'", "host '2001:db8::7'").replace("port 1080", "port 0") + check("an IPv6 host in brackets, a port of 0 left out", + linux(Gsettings(v6)).user().server == "http=[2001:db8::7]:3128;socks=proxy.example") + odd = MANUAL.replace("mode 'manual'", "mode 'unheard-of'") + with pytest.raises(ValueError, match="does not know"): + linux(Gsettings(odd)).user() + + +def test_gvariant_text_reads_as_g_variant_print_writes_it(): + cases = {"'plain'": "plain", '"it\'s"': "it's", r"'a\'b\\c\n'": "a'b\\c\n", + r"'é\U0001F600'": "é\U0001F600", "@as []": [], "[]": [], + "['a', \"b'c\"]": ["a", "b'c"], "8080": 8080, "-1": -1, "true": True, + "false": False} + for text, want in cases.items(): + value, rest = proxy._value(text) + check(f"{text} -> {want!r}", (value, rest) == (want, ""), f"({value!r}, {rest!r})") + for broken in ("'unclosed", "['a'", "nothing", "['a' 'b']"): + with pytest.raises(ValueError): + proxy._value(broken) + with pytest.raises(ValueError, match="more after a value"): + proxy.gnome_values("org.gnome.system.proxy mode 'none' 'again'\n") + + +def test_under_sudo_the_desktop_asked_is_the_persons_and_nothing_else_reaches_the_child( + monkeypatch): + # An account database that knows everyone: "" must still mean "no home", on a + # runner with a uid 1000 as on one without. + monkeypatch.setattr(proxy.paths, "home_of", lambda uid: "/home/looked-up") + run = Gsettings() + linux(run, person=(1000, 1000), own_uid=0).user() + argv, env = run.calls[0] + check("started as the person who typed sudo", argv == [ + "/usr/bin/setpriv", "--reuid=1000", "--regid=1000", "--init-groups", + "/usr/bin/gsettings", "list-recursively", "org.gnome.system.proxy"], f"({argv})") + check("with their home, a fixed PATH and UTF-8 in English, and nothing of root's", + env == {"HOME": HOME, "PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8"}) + run = Gsettings() + linux(run, person=None, own_uid=1000).user() + check("without sudo: gsettings itself, as this account", + run.calls[0][0][0] == "/usr/bin/gsettings") + run = Gsettings() + linux(run, person=(0, 0), own_uid=0, home="/root").user() + check("root that typed sudo: nobody to become", run.calls[0][0][0] == "/usr/bin/gsettings") + with pytest.raises(OSError, match="setpriv was not found"): + linux(person=(1000, 1000), own_uid=0, found=("gsettings",)).user() + with pytest.raises(OSError, match="no home directory"): + linux(person=(1000, 1000), own_uid=0, home="").user() + + +def test_the_one_program_started_gets_nothing_of_this_process(monkeypatch): + seen = [] + monkeypatch.setattr(proxy.subprocess, "run", lambda argv, **kwargs: seen.append( + (argv, kwargs)) or "done") + check("the run's answer is handed back", proxy._run(["/usr/bin/gsettings"], {"HOME": HOME}) + == "done") + argv, kwargs = seen[0] + check("only the environment it is given, no terminal, a time limit, its output kept", + kwargs == {"stdin": subprocess.DEVNULL, "capture_output": True, + "timeout": proxy.GSETTINGS_WAIT_S, "env": {"HOME": HOME}, "check": False}, + f"({kwargs})") + check("the default reader runs through it", proxy._Linux( + which=which(), person=None, own_uid=1000, home=HOME).run is proxy._run) + + +def test_a_system_without_the_desktops_settings_has_no_such_part(): + check("no gsettings", linux(found=()).user() is None) + check("no such schema", linux(Gsettings("", "No such schema “org.gnome.system.proxy" + "”\n", 1)).user() is None) + memory = ("GLib-GIO-Message: Using the 'memory' GSettings backend. Your settings will " + "not be saved or shared with other applications.\n") + check("GLib with nowhere the desktop keeps them", linux(Gsettings(NONE, memory)).user() is None) + with pytest.raises(OSError, match="gsettings answered 2: something else"): + linux(Gsettings("", "something else\nmore\n", 2)).user() + + +def test_a_gsettings_that_fails_costs_only_the_desktops_part(monkeypatch, tmp_path): + monkeypatch.setattr(proxy.crashlog, "note", lambda *a: None) + native = linux(Gsettings(raises=subprocess.TimeoutExpired(["gsettings"], 2)), + root=str(tmp_path)) + reading = proxy.read(native) + check("the desktop's part says why", reading.user is None + and "TimeoutExpired" in reading.user_error, f"({reading})") + check("Linux: no WinHTTP part, and that is not a failure", + (reading.machine, reading.machine_error, reading.system) == (None, "", "linux")) + check("the variables still read", reading.env == () and reading.env_error == "") + check("unreadable for the part that failed", logic.unreadable(reading)) + calm = proxy.read(linux(Gsettings(NONE), root=str(tmp_path))) + check("and not for a part the system lacks", not logic.unreadable(calm), f"({calm})") + + +def _write(base, relative, text): + path = os.path.join(base, *relative.split("/")) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(text) + + +def test_the_variables_come_from_every_file_a_new_program_gets_them_from(tmp_path): + root, home = str(tmp_path / "root"), str(tmp_path / "home") + _write(root, "etc/environment", 'PATH="/usr/bin"\nhttp_proxy="http://192.0.2.1:3128"\n' + "# no_proxy=commented\n") + _write(root, "usr/lib/environment.d/50-proxy.conf", "https_proxy=http://192.0.2.2:3128\n") + _write(root, "etc/environment.d/50-proxy.conf", "https_proxy='http://192.0.2.3:3128'\n") + _write(root, "etc/environment.d/60-more.conf", "export HTTP_PROXY=http://192.0.2.4:8080\n") + _write(home, ".config/environment.d/10-mine.conf", + "http_proxy=http://u:pw@192.0.2.9:3128\nno_proxy=localhost,${OTHER}\nnot a line\n") + native = linux(root=root, home=home, own_uid=os.stat(home).st_uid, person=None) + got = {v.name: v for v in native.env()} + check("case-sensitive names, in the program's order", list(got) == [ + "http_proxy", "HTTP_PROXY", "https_proxy", "no_proxy"], f"({list(got)})") + check("the person's file wins over every account's, and says so", + got["http_proxy"] == proxy.Variable("http_proxy", "http://u:pw@192.0.2.9:3128", "user", + replaces=True)) + check("a file in /etc replaces the one of the same name in /usr/lib, quotes off", + got["https_proxy"] == proxy.Variable("https_proxy", "http://192.0.2.3:3128", "machine")) + check("export allowed, a variable shown as written", + (got["HTTP_PROXY"].value, got["no_proxy"].value) + == ("http://192.0.2.4:8080", "localhost,${OTHER}")) + + +def test_an_empty_variable_unsets_it_and_sends_nobody_anywhere(tmp_path): + root, home = str(tmp_path / "root"), str(tmp_path / "home") + _write(root, "etc/environment", "http_proxy=http://192.0.2.1:3128\nno_proxy=localhost\n") + _write(home, ".config/environment.d/10-mine.conf", "http_proxy=\nNO_PROXY=''\n") + native = linux(root=root, home=home, own_uid=os.stat(home).st_uid, person=None) + got = native.env() + check("the person's empty http_proxy= unsets every account's; empty is not a value", + got == (proxy.Variable("no_proxy", "localhost", "machine"),), f"({got})") + reading = proxy.Reading(proxy.NOTHING, "", None, "", got, "", "linux") + check("and nothing is in use", not logic.in_use(reading)) + + +def test_the_programs_started_are_found_where_the_child_looks_never_on_this_path(monkeypatch): + asked = [] + monkeypatch.setattr(proxy.shutil, "which", lambda name, path=None: asked.append( + (name, path)) or None) + native = proxy._Linux(person=(1000, 1000), own_uid=0, home=HOME) + check("nothing found: no desktop to ask", native.user() is None) + check("looked up in CHILD_PATH only", asked == [("gsettings", proxy.CHILD_PATH)], + f"({asked})") + + +def test_a_persons_files_that_are_not_theirs_are_not_read(tmp_path): + root, home = str(tmp_path / "root"), str(tmp_path / "home") + _write(home, ".config/environment.d/10-mine.conf", "http_proxy=http://192.0.2.9:3128\n") + stranger = os.stat(home).st_uid + 4242 + native = linux(root=root, home=home, person=(stranger, stranger), own_uid=0) + check("a directory someone else owns is skipped", native.env() == ()) + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="links and owners of Linux") +def test_a_link_to_a_file_the_person_does_not_own_is_not_read(tmp_path): # pragma: no cover - Linux + root, home = str(tmp_path / "root"), str(tmp_path / "home") + _write(home, ".config/environment.d/keep.conf", "https_proxy=http://192.0.2.8:3128\n") + _write(str(tmp_path), "other/secret", "http_proxy=http://192.0.2.66:1\n") + os.symlink(str(tmp_path / "other" / "secret"), + os.path.join(home, ".config", "environment.d", "10-link.conf")) + own = os.stat(home).st_uid + native = linux(root=root, home=home, person=None, own_uid=own) + check("their own link to their own file reads", + [v.name for v in native.env()] == ["http_proxy", "https_proxy"]) + native.uid = own + 1 # the same files, as someone else's + check("as another account's files: nothing", native.env() == ()) + + +def test_the_rows_and_the_report_say_linuxs_places(): + reading = proxy.Reading(None, "", None, "", ( + proxy.Variable("http_proxy", "http://192.0.2.9:3128", "user"), + proxy.Variable("HTTP_PROXY", "http://192.0.2.4:8080", "machine")), "", "linux") + rows = logic.details(reading) + check("the desktop's part: not on this system", rows[0].item == Words( + "tools.adapters.proxy.item.desktop") and rows[0].value == Words( + "tools.adapters.proxy.value.not_here"), f"({rows[0]})") + check("no WinHTTP rows", not [r for r in rows if r.key.startswith("winhttp")]) + by_name = {r.item: r for r in rows if isinstance(r.item, str)} + check("lowercase http_proxy sends programs to its server, offered to the Control page", + by_name["http_proxy"].address == "192.0.2.9" and logic.in_use(reading)) + check("uppercase HTTP_PROXY: curl does not read it", + Words("tools.adapters.proxy.note.curl_lowercase") in by_name["HTTP_PROXY"].note) + windows = reading._replace(system="windows", user=proxy.NOTHING, + machine=proxy.MachineProxy("", "")) + check("on Windows the same name has no such note", + Words("tools.adapters.proxy.note.curl_lowercase") not in { + n for r in logic.details(windows) for n in r.note}) + report = logic.report_lines(reading) + check("the report: the desktop's part by its name, no WinHTTP line", report == [ + "proxy, desktop (GNOME): not on this system", + "proxy variables: http_proxy=http://192.0.2.9:3128 (user), " + "HTTP_PROXY=http://192.0.2.4:8080 (machine)"], f"({report})") + detect = reading._replace(user=proxy.UserProxy(True, "", "", "")) + check("detection on Linux: programs look, not Windows", Words( + "tools.adapters.proxy.note.detect_linux") in logic.details(detect)[0].note) + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="this runner's own system") +def test_this_machines_proxy_reads_without_raising(): # pragma: no cover - Linux + reading = proxy.read() + check("Linux's places", reading.system == "linux" and reading.machine is None + and reading.machine_error == "", f"({reading})") diff --git a/tests/test_rtnetlink.py b/tests/test_rtnetlink.py new file mode 100644 index 00000000..ea5e7a24 --- /dev/null +++ b/tests/test_rtnetlink.py @@ -0,0 +1,257 @@ +"""``beantester/netlink.py`` and ``beantester/rtnetlink.py``: the kernel's tables of +adapters, addresses and routes on Linux, and the transport under them. + +The fixture is a real kernel's own answers (Ubuntu 26.04.1, kernel 7.0.0), recorded +inside a network namespace built for it - veth, dummy, bridge, vlan, tun and WireGuard; +an address with a lease, one deprecated, one another device also holds (DAD failed); +two IPv4 default routes and an IPv6 one - so it carries documentation addresses only +(``internal_tools`` probes ``2026-10-27-linux-adapters``, where every field decoded here +was compared with ``ip -j``: no difference). The requests below are the ones that +kernel answered, byte for byte. +""" +import json +import os +import shutil +import struct +import subprocess +import sys + +import pytest + +from beantester import netlink, rtnetlink +from fakes import check + +FIXTURE = os.path.join(os.path.dirname(__file__), "data", "rtnetlink", "netns-kernel-7.0.jsonl") + + +def _recorded(): + with open(FIXTURE, encoding="utf-8") as f: + return [json.loads(line) for line in f] + + +RECORDED = _recorded() +BY_LABEL = {r["label"]: r for r in RECORDED} + + +class FakeNetlink: + """A netlink socket that answers with the datagrams it was given, one per read.""" + + def __init__(self, *datagrams, sender=(0, 0)): + self.datagrams = list(datagrams) + self.sender = sender + 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), [], 0, self.sender + + +def kernel(extra=None): + """``talk`` answering each request the recorded kernel answered, through the real + ``netlink.exchange``; ``extra`` maps more requests to datagrams. Keeps the requests.""" + answers = {bytes.fromhex(r["request"]): [bytes.fromhex(a) for a in r["answer"]] + for r in RECORDED} + answers.update(extra or {}) + sent = [] + + def talk(request): + sent.append(request) + if request not in answers: + raise AssertionError(f"a request the kernel was never asked: {request.hex()}") + return netlink.exchange(request, lambda: FakeNetlink(*answers[request])) + talk.sent = sent + return talk + + +def error(code, flags=0): + """An NLMSG_ERROR datagram carrying ``-code`` (0: an acknowledgement).""" + return struct.pack("=IHHII", 36, netlink.NLMSG_ERROR, flags, 1, 0) + struct.pack( + "=i", -code) + bytes(16) + + +def route_answer(oif, family=rtnetlink.LINUX_AF_INET): + """A route lookup's answer: the route out through ``oif``, then the acknowledgement.""" + body = (rtnetlink._ROUTE.pack(family, 32, 0, 0, 254, 0, 0, 1, 0) + + netlink.attribute(rtnetlink.RTA_OIF, struct.pack("=I", oif))) + return [netlink.message(rtnetlink.RTM_NEWROUTE, 0, body), error(0)] + + +def by_name(tables): + return {link.name: link for link in tables.links} + + +def test_the_kernels_own_tables_read_as_ip_showed_them(): + talk = kernel() + tables = rtnetlink.read(talk) + links = by_name(tables) + check("every adapter of the namespace", set(links) == { + b"lo", b"bv0", b"dm0", b"br0", b"dm1", b"dm0.7", b"tn0", b"wg0"}, f"({set(links)})") + check("a virtual device says what it is", + [links[n].kind for n in (b"bv0", b"dm0", b"br0", b"dm0.7", b"tn0", b"wg0")] + == ["veth", "dummy", "bridge", "vlan", "tun", "wireguard"]) + check("lo: the loopback, UNKNOWN, up with its link", + (links[b"lo"].arphrd, links[b"lo"].operstate, links[b"lo"].flags & 0x10001) + == (772, 0, 0x10001)) + check("a bridge with nothing in it is down (IF_OPER_DOWN)", links[b"br0"].operstate == 2) + check("WireGuard: no link layer, no address, its own MTU", + (links[b"wg0"].arphrd, links[b"wg0"].address, links[b"wg0"].mtu) == (65534, b"", 1420)) + check("a MAC is six bytes", len(links[b"bv0"].address) == 6) + + addresses = {a.ip: a for a in tables.addresses} + leased = addresses["198.51.100.3"] + check("an address with a lease: no PERMANENT, its lifetimes", + not leased.flags & 0x80 and 90 <= leased.preferred <= 100 and 290 <= leased.valid <= 300, + f"({leased})") + check("one deprecated: preferred 0", addresses["198.51.100.4"].preferred == 0) + check("one another device holds: DADFAILED, with TENTATIVE", + addresses["2001:db8:1::5"].flags & 0x48 == 0x48) + check("the kernel says it made ::1 and the link-local address (IFA_PROTO)", + (addresses["::1"].proto, addresses["fe80::fc3e:b9ff:fe84:591e"].proto) == (1, 3)) + check("an address is on its adapter", addresses["203.0.113.1"].ifindex == links[b"dm0"].ifindex) + + defaults = sorted((r.family, r.gateway, r.oif, r.priority, r.protocol) + for r in tables.routes if r.dst_len == 0 and r.table == 254) + bv0, dm0 = links[b"bv0"].ifindex, links[b"dm0"].ifindex + check("the default routes, each with its way out, metric and origin", defaults == [ + (2, "198.51.100.1", bv0, 100, 16), (2, "203.0.113.254", dm0, 200, 3), + (10, "2001:db8:1::1", bv0, 1024, 3)], f"({defaults})") + recorded = [bytes.fromhex(BY_LABEL[label]["request"]) + for label in ("links", "addresses", "routes2", "routes10")] + check("asked exactly as the kernel was asked", talk.sent == recorded, + f"({[m.hex() for m in talk.sent]})") + + +def test_a_route_lookup_is_what_ip_route_get_asks_and_sends_nothing(): + talk = kernel() + check("8.8.8.8 leaves through bv0", rtnetlink.route_to("8.8.8.8", talk) == 2) + check("so does the IPv6 probe", rtnetlink.route_to("2001:4860:4860::8888", talk) == 2) + check("the stub resolver is the loopback", rtnetlink.route_to("127.0.0.53", talk) == 1) + check("asked as the kernel was asked, with an acknowledgement", + talk.sent == [bytes.fromhex(BY_LABEL[label]["request"]) for label in + ("get 8.8.8.8", "get 2001:4860:4860::8888", "get 127.0.0.53")]) + check("NLM_F_ACK in every lookup", all( + struct.unpack_from("=H", m, 6)[0] == netlink.NLM_F_REQUEST | netlink.NLM_F_ACK + for m in talk.sent)) + + +def test_no_route_is_no_adapter_and_any_other_refusal_an_error(): + for code in (100, 101, 113): # ENETDOWN, ENETUNREACH, EHOSTUNREACH + talk = kernel({bytes.fromhex(BY_LABEL["get 2001:4860:4860::8888"]["request"]): + [error(code)]}) + check(f"errno {code}: no adapter is the way out", + rtnetlink.route_to("2001:4860:4860::8888", talk) == 0) + talk = kernel({bytes.fromhex(BY_LABEL["get 8.8.8.8"]["request"]): [error(1)]}) + with pytest.raises(OSError, match="refused a route lookup"): + rtnetlink.route_to("8.8.8.8", talk) + + +def _interrupted(datagram): + """The datagram with NLM_F_DUMP_INTR set on its first message.""" + flags = struct.unpack_from("=H", datagram, 6)[0] | netlink.NLM_F_DUMP_INTR + return datagram[:6] + struct.pack("=H", flags) + datagram[8:] + + +def test_a_list_that_changed_while_it_was_read_is_read_again_and_then_refused(): + request = bytes.fromhex(BY_LABEL["addresses"]["request"]) + datagrams = [bytes.fromhex(a) for a in BY_LABEL["addresses"]["answer"]] + answers = [[_interrupted(datagrams[0]), *datagrams[1:]], datagrams] + + def once_then_calm(message): + real = kernel() + if message == request and answers: + return netlink.exchange(message, lambda: FakeNetlink(*answers.pop(0))) + return real(message) + tables = rtnetlink.read(once_then_calm) + check("the second, calm read is the one kept", len(tables.addresses) == 13 and not answers, + f"({len(tables.addresses)})") + + def always_changing(message): + found = [bytes.fromhex(a) for a in BY_LABEL["links"]["answer"]] + if message == bytes.fromhex(BY_LABEL["links"]["request"]): + return netlink.exchange(message, lambda: FakeNetlink(_interrupted(found[0]), + *found[1:])) + return kernel()(message) + with pytest.raises(rtnetlink.Changing, match="kept changing"): + rtnetlink.read(always_changing) + + +def test_a_kernel_without_ipv6_still_lists_and_a_refused_list_is_an_error(): + v6 = bytes.fromhex(BY_LABEL["routes10"]["request"]) + tables = rtnetlink.read(kernel({v6: [error(97)]})) # EAFNOSUPPORT: ipv6.disable=1 + check("no IPv6 route, the rest read", not [r for r in tables.routes if r.family == 10] + and tables.links, f"({len(tables.routes)})") + v4 = bytes.fromhex(BY_LABEL["routes2"]["request"]) + with pytest.raises(OSError, match="refused the list of routes"): + rtnetlink.read(kernel({v4: [error(13)]})) + with pytest.raises(OSError, match="refused the list of routes"): + rtnetlink.read(kernel({v6: [error(13)]})) # only EAFNOSUPPORT means "none" + + +def test_a_route_with_several_ways_out_is_one_row_per_way(): + hop = struct.Struct("=HBBi") + ways = b"" + for oif, gateway in ((2, bytes([198, 51, 100, 1])), (3, bytes([203, 0, 113, 254]))): + inner = netlink.attribute(rtnetlink.RTA_GATEWAY, gateway) + ways += hop.pack(hop.size + len(inner), 0, 0, oif) + inner + via = struct.pack("=H", 10) + bytes.fromhex("20010db8000100000000000000000001") + multipath = (rtnetlink._ROUTE.pack(2, 0, 0, 0, 254, 3, 0, 1, 0) + + netlink.attribute(rtnetlink.RTA_MULTIPATH, ways)) + through_v6 = (rtnetlink._ROUTE.pack(2, 0, 0, 0, 254, 3, 0, 1, 0) + + netlink.attribute(rtnetlink.RTA_OIF, struct.pack("=I", 4)) + + netlink.attribute(rtnetlink.RTA_VIA, via)) + rows = rtnetlink._routes(multipath) + rtnetlink._routes(through_v6) + check("each way its adapter and gateway; a gateway given as RTA_VIA", + [(r.oif, r.gateway) for r in rows] + == [(2, "198.51.100.1"), (3, "203.0.113.254"), (4, "2001:db8:1::1")], f"({rows})") + + +def test_an_answer_that_does_not_add_up_is_an_error_never_a_shorter_list(): + datagram = bytes.fromhex(BY_LABEL["links"]["answer"][0]) + with pytest.raises(OSError, match="not the kernel"): + netlink.exchange(b"r", lambda: FakeNetlink(datagram, sender=(4242, 0))) + with pytest.raises(OSError, match="longer than its message"): + netlink.attributes(struct.pack("=HH", 40, 3) + b"lo\0\0") + with pytest.raises(OSError, match="longer than its datagram"): + netlink.exchange(b"r", lambda: FakeNetlink(datagram[:100])) + nl = FakeNetlink(_interrupted(datagram), bytes.fromhex(BY_LABEL["links"]["answer"][1]), + bytes.fromhex(BY_LABEL["links"]["answer"][2])) + replies = netlink.exchange(b"r", lambda: nl) + check("a list the kernel says changed is marked so", replies.interrupted) + check("sent to the kernel, and closed", nl.sent == [(b"r", (0, 0))] and nl.closed) + + +def test_an_attribute_is_padded_to_four_bytes(): + check("4 + 3 bytes, then one of padding", + netlink.attribute(3, b"abc") == struct.pack("=HH", 7, 3) + b"abc\0") + check("and read back as written", netlink.attributes(netlink.attribute(3, b"abc")) + == [netlink.Attribute(3, b"abc")]) + + +@pytest.mark.skipif(not sys.platform.startswith("linux") or shutil.which("ip") is None, + reason="this runner's own kernel, against ip -j") +def test_this_machines_tables_read_as_ip_reads_them(): # pragma: no cover - Linux + tables = rtnetlink.read() + + def ip(*args): + return json.loads(subprocess.run(["ip", "-j", *args], capture_output=True, text=True, + timeout=10, check=True).stdout or "[]") + theirs = {link["ifindex"]: (link["ifname"], link["mtu"]) for link in ip("link")} + mine = {link.ifindex: (link.name.decode(), link.mtu) for link in tables.links} + check("the adapters, their names and MTU", mine == theirs, f"({mine} vs {theirs})") + theirs = {(link["ifindex"], a["local"], a["prefixlen"]) for link in ip("addr") + for a in link.get("addr_info", [])} + mine = {(a.ifindex, a.ip, a.prefix) for a in tables.addresses} + check("every address with its prefix", mine == theirs, f"({mine ^ theirs})") + route = ip("route", "get", "8.8.8.8") + names = {link.ifindex: link.name.decode() for link in tables.links} + check("the way out the kernel names", names.get(rtnetlink.route_to("8.8.8.8")) + == (route[0].get("dev") if route else None)) diff --git a/tests/test_toolbox.py b/tests/test_toolbox.py index e8039829..f72c1f18 100644 --- a/tests/test_toolbox.py +++ b/tests/test_toolbox.py @@ -520,7 +520,12 @@ def test_diagnostics_on_linux_speaks_of_linux_and_greys_out_what_it_cannot_read( dg.cleanup_blocker = lambda: capture_backend.current().CLEANUP_BLOCKER real_is_windows = driver.is_windows + import sys + real_platform = sys.platform + def build(windows, backend, readable): + # the platform too: a box's tip follows it (gui/toolbox/diagnostics.box_tip) + sys.platform = "win32" if windows else "linux" driver.is_windows = lambda: windows capture_backend._CURRENT = backend for spec in OPTIONAL_BOXES: @@ -529,6 +534,7 @@ def build(windows, backend, readable): return DiagnosticsPanel(app, app.root) finally: driver.is_windows = real_is_windows + sys.platform = real_platform def sheet(panel): opened = [] diff --git a/tools/ci_linux_capture_smoke.py b/tools/ci_linux_capture_smoke.py index da21c434..6546330a 100644 --- a/tools/ci_linux_capture_smoke.py +++ b/tools/ci_linux_capture_smoke.py @@ -32,6 +32,12 @@ 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). +* The Tools tab's Adapters, its own ``adapters.read``, against ``ip -j`` of the same + tables: in the namespace, given an address with a lease, a deprecated one and a + duplicate (the same IPv6 address on both ends of a veth pair, so DAD finds it), each + of which it must name; and on this machine as it is. Adapters, MTU, MAC, state, every + address with its prefix, DAD state and lease, and the adapter the route out leaves + through - field by field, as the probe that found no difference compared them. What bounds the damage (convention 6) ------------------------------------- @@ -52,6 +58,7 @@ import subprocess import sys import threading +import time ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) NS = "bntci" @@ -139,6 +146,32 @@ "print(json.dumps({'found': len(served), 'stale': stale, 'listener': listener}))\n") +# Run INSIDE the namespace (and once outside it), from the checkout: the Adapters tool's +# own read (adapters.read), and what `ip -j` says of the same tables. +ADAPTERS_CLIENT = ( + "import json, subprocess\n" + "from beantester import adapters\n" + "def ip(*a):\n" + " r = subprocess.run(['ip', '-j', *a], capture_output=True, text=True, timeout=10)\n" + " return json.loads(r.stdout or '[]')\n" + "r = adapters.read()\n" + "rows = [{'name': a.name, 'index': a.if_index, 'status': a.status, 'mtu': a.mtu,\n" + " 'mac': a.mac, 'addresses': [[x.ip, x.prefix, x.origin, x.dad] for x in a.addresses]}\n" + " for a in r.adapters]\n" + "print(json.dumps({'product': rows, 'internet': r.internet.get(4, 0), 'link': ip('link'),\n" + " 'addr': ip('addr'), 'route': ip('route', 'get', '8.8.8.8')}))\n") +# Addresses in the states the Adapters tool names, made in the namespace. A duplicate is +# what DAD finds when the address is on both ends of a veth pair: one end answers for it +# (nodad) while the other asks. +LEASED, DEPRECATED, DUPLICATE6 = "203.0.113.5", "203.0.113.6", "2001:db8:ac::1" +# adapters.py's numbers (AddressRow.origin, .dad), written out: this script runs with +# the checkout only on the namespace's side. tests/test_ci_linux_capture_smoke.py holds +# them equal to the module's. +ORIGIN_WELL_KNOWN, ORIGIN_DHCP, ORIGIN_ROUTER, ORIGIN_LEASE = 2, 3, 4, -1 +DAD_TENTATIVE, DAD_DUPLICATE, DAD_DEPRECATED, DAD_PREFERRED = 1, 2, 3, 4 +STATUS_UP = 1 + + # -- the judges: pure, so a test can prove they fail ------------------------------ # def _answered(rtts): return [r for r in rtts if r is not None] @@ -228,6 +261,91 @@ def judge_reset(plain, impaired, stale): return failures +def _ip_dad(address): + if address.get("dadfailed"): + return DAD_DUPLICATE + if address.get("tentative"): + return DAD_TENTATIVE + return DAD_DEPRECATED if address.get("deprecated") else DAD_PREFERRED + + +def _ip_up(link): + """What the tool calls up: UP, or UNKNOWN switched on with its link.""" + state, flags = link.get("operstate"), set(link.get("flags", [])) + return state == "UP" or (state == "UNKNOWN" and {"UP", "LOWER_UP"} <= flags) + + +def _judge_links(name, mine, theirs, where, private): + failures = [] + if mine["index"] != theirs.get("ifindex"): + failures.append(f"adapters {where}: {name} index {mine['index']}, ip says " + f"{theirs.get('ifindex')}") + if mine["mtu"] != theirs.get("mtu"): + failures.append(f"adapters {where}: {name} MTU {mine['mtu']}, ip says {theirs.get('mtu')}") + mac = theirs.get("address", "") if theirs.get("link_type") == "ether" else mine["mac"] + if mine["mac"] != mac: + said = "" if private else f" {mine['mac']!r}, ip says {mac!r}" + failures.append(f"adapters {where}: {name} MAC differs{said}") + if (mine["status"] == STATUS_UP) != _ip_up(theirs): + failures.append(f"adapters {where}: {name} status {mine['status']}, ip says " + f"{theirs.get('operstate')} {theirs.get('flags')}") + return failures + + +def _judge_address(key, mine, theirs, where, private): + shown = f"an address on {key[0]}" if private else str(key) + failures = [] + if mine[3] != _ip_dad(theirs): + failures.append(f"adapters {where}: {shown} DAD state {mine[3]}, ip says {_ip_dad(theirs)}") + leased = mine[2] in (ORIGIN_DHCP, ORIGIN_ROUTER, ORIGIN_LEASE) + if mine[2] != ORIGIN_WELL_KNOWN and leased != bool(theirs.get("dynamic")): + failures.append(f"adapters {where}: {shown} origin {mine[2]}, ip says dynamic " + f"{bool(theirs.get('dynamic'))}") + return failures + + +def judge_adapters(seen, where, states=False, private=False): + """Failures (empty = pass): the Adapters tool's read of a namespace against ``ip -j`` + of the same one - adapters, index, MTU, MAC, state; every address, its prefix, its + DAD state and whether it has a lease; the adapter the route out leaves through. + ``states``: the namespace was made with a lease, a deprecated address and a + duplicate, and the tool must have seen each - or the comparison proved nothing. + ``private``: the machine's own adapters - a failure names the adapter and what + differs, never its addresses or MAC, which a log pasted anywhere would carry.""" + product = {a["name"]: a for a in seen.get("product", [])} + links = {link["ifname"]: link for link in seen.get("link", [])} + if not product: + return [f"adapters {where}: the tool listed no adapter"] + failures = [] + if set(product) != set(links): + failures.append(f"adapters {where}: {sorted(product)}, ip lists {sorted(links)}") + for name in sorted(set(product) & set(links)): + failures += _judge_links(name, product[name], links[name], where, private) + theirs = {(link["ifname"], a["local"], a["prefixlen"]): a + for link in seen.get("addr", []) for a in link.get("addr_info", [])} + mine = {(name, x[0], x[1]): x for name, a in product.items() for x in a["addresses"]} + differ = sorted(set(mine) ^ set(theirs)) + if differ: + said = (f"{len(differ)}, on {sorted({key[0] for key in differ})}" if private + else str(differ)) + failures.append(f"adapters {where}: addresses differ: {said}") + for key in sorted(set(mine) & set(theirs)): + failures += _judge_address(key, mine[key], theirs[key], where, private) + route = seen.get("route") or [] + out = links.get(route[0].get("dev"), {}).get("ifindex", 0) if route else 0 + if seen.get("internet") != out: + failures.append(f"adapters {where}: the route out through {seen.get('internet')}, " + f"ip says {out}") + # By address AND state: the duplicate is on both ends of its pair, in use on one. + for ip, field, value, what in ((LEASED, 2, ORIGIN_DHCP, "a lease"), + (DEPRECATED, 3, DAD_DEPRECATED, "deprecated"), + (DUPLICATE6, 3, DAD_DUPLICATE, "a duplicate")): + if states and not any(x[0] == ip and x[field] == value for x in mine.values()): + failures.append(f"adapters {where}: {ip} not seen as {what} " + f"({[x for x in mine.values() if x[0] == ip]})") + return failures + + def skip_reason(platform, euid, library_loaded): """Why this machine cannot run the check, or None.""" if not platform.startswith("linux"): @@ -485,6 +603,45 @@ def _reset_phase(): far.close() +def _read_adapters(argv): + r = subprocess.run([*argv, sys.executable, "-c", ADAPTERS_CLIENT], cwd=ROOT, + capture_output=True, text=True, timeout=60) + return json.loads(r.stdout.strip() or "{}") + + +def _adapters_phase(): + """The Adapters tool in the namespace, with an address in each state it names, and on + this machine as it is - each against ``ip -j``. Everything made here is the + namespace's and goes with it.""" + dummy, pair = f"{NS}-d", (f"{NS}-a", f"{NS}-b") + _ip("-n", NS, "link", "add", dummy, "type", "dummy") + _ip("-n", NS, "link", "set", dummy, "up") + _ip("-n", NS, "addr", "add", f"{LEASED}/24", "dev", dummy, "valid_lft", "300", + "preferred_lft", "100") + _ip("-n", NS, "addr", "add", f"{DEPRECATED}/24", "dev", dummy, "valid_lft", "300", + "preferred_lft", "0") + _ip("-n", NS, "link", "add", pair[0], "type", "veth", "peer", "name", pair[1]) + for end in pair: + _ip("-n", NS, "link", "set", end, "up") + _ip("-n", NS, "addr", "add", f"{DUPLICATE6}/64", "dev", pair[0], "nodad") + _ip("-n", NS, "addr", "add", f"{DUPLICATE6}/64", "dev", pair[1]) + _ip("-n", NS, "route", "add", "default", "via", HOST) + for _ in range(50): # DAD takes about a second + shown = subprocess.run(["ip", "-n", NS, "-j", "addr", "show", "dev", pair[1]], + capture_output=True, text=True, timeout=10).stdout + if '"dadfailed"' in shown: + break + time.sleep(0.1) + inside = _read_adapters(in_namespace()) + outside = _read_adapters([]) + # The namespace is this script's own, documentation addresses; of the machine itself + # only how many adapters it has - its addresses and names stay out of the log. + print("adapters:", json.dumps({"namespace": inside.get("product"), + "this machine": len(outside.get("product", []))})) + return judge_adapters(inside, "in the namespace", states=True) + judge_adapters( + outside, "on this machine", private=True) + + def main(argv=None, environment=None): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--require", action="store_true", @@ -510,6 +667,7 @@ def main(argv=None, environment=None): failures += _loss_phase() failures += _ping_phase() failures += _reset_phase() + failures += _adapters_phase() # last: it gives the namespace a default route except Exception as exc: # a phase that could not run is a failure failures.append(f"{type(exc).__name__}: {exc}") finally: