diff --git a/CHANGELOG.md b/CHANGELOG.md index 9917761..0c0d061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ### Added +- **Sockets, on the Tools tab.** Every TCP and UDP socket on this computer, like + `netstat -ano`, with no session needed: what is listening, what is connected, its state + and the program that owns it. See which port your application listens on before you + impair it, or which program holds a port. Search it the way you search the connection + table (`lport:8080`, `state:listen`), and right-click a row to target its program or to + limit to or block its remote address. It is the first tab of the Tools page. + - **A Tools tab, with a filter tester.** Pick a field from the Control page, type an expression and a value, and see at once whether they match - and which part of the expression decided it, for example the `!10.0.5.0/24` that excluded `10.0.5.7`. A value @@ -63,6 +70,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol refused. Every full flag still works, so saved reproduction commands and every example in this documentation are unaffected - only hand-typed abbreviations need writing out in full. +- **A middle click on the connection table no longer opens its row menu.** The menu opens + on a right click, or from the keyboard with Shift+F10 or the menu key, as before. + ### Docs - **The README now points at the website.** One link under the badges, and a second after the diff --git a/README.md b/README.md index 34011eb..d469f76 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,14 @@ monitor is gone it returns to the centre of the current screen. 50 000). - **Tools** - small helpers for questions you have before or during a test, one sub-tab each. Nothing on this tab sends anything over the network. Today: + - **Sockets** - every TCP and UDP socket on this computer, like `netstat -ano`, and no + session is needed: what is listening, what is connected, its state (LISTEN, + ESTABLISHED, TIME_WAIT...) and the program that owns it. Use it to see which port your + application listens on before you impair it, or which program holds a port. The table + is read when you open the tab and when you press **Refresh**. Search it the way you + search the connection table (`lport:8080`, `state:listen`, `proc:chrome`), and + right-click a row to target its program, leave it alone, or limit to or block its + remote address. Program names are shown without administrator rights. - **Filter tester** - pick a field from the Control page (target process, destination IP or port, blocked IP or port), type an expression and a value, and see at once whether they match. It also shows which part of the expression selected the value, which `!` part @@ -229,7 +237,7 @@ effect. | `Ctrl+Enter` | Apply changes | | `Ctrl+S` / `Ctrl+O` | Save / Load config file | | `Ctrl+L` | Clear the log | -| `Ctrl+F` | Search: the field search on Control, the table search on Connections | +| `Ctrl+F` | Search: the field search on Control, the table search on Connections, the socket search on Tools > Sockets | **Finding a setting.** The box at the top of the Control page searches the settings by name - type part of a field or section name, or the command-line flag such as `--loss`. Every match is @@ -1348,7 +1356,8 @@ beantester/ the implementation package fields.py FIELD REGISTRY - single source of truth: type, label, unit, range, form section, profile scope, CLI flag validators.py number and range validation (shared by GUI, CLI and config file) - portmap.py socket table: local port -> PID (iphlpapi/ctypes; psutil fallback) + portmap.py socket table: local port -> PID (iphlpapi/ctypes; psutil fallback), + and every socket with its state for the Tools tab targeting.py live target port set: process tree, asks for a rebuild on a miss target_resolver.py rebuilds that port set on its own thread, off the packet path socketwatch.py live local port -> PID from WinDivert SOCKET events (event-driven source) @@ -1371,10 +1380,10 @@ beantester/ the implementation package prefs.py GUI preferences (language, chart, log) stored in ui.json pages/ page registry: control, stats (3 sub-tabs), conns, toolbox (Tools) toolbox/ the Tools tab: its registry of tools and one panel per tool - field_actions.py filling a Control-page field from elsewhere (table menu, Tools tab) + field_actions.py filling a Control-page field from elsewhere (table row menus, Tools tab) clipboard.py copying a whole text, confirmed by reading the clipboard back panels/ secondary windows: "About", "Settings" and the pop-out event log - widgets/ SortableTree (sorting, row diff, Ctrl+C, column-width cap) + widgets/ SortableTree (sorting, row diff, Ctrl+C, column-width cap, row menu) model_worker.py rebuilds a table's model on a worker thread (UI never blocks) windows.py base class and registry for secondary windows dialogs.py dark, in-app replacements for messagebox/simpledialog diff --git a/beantester/gui/field_actions.py b/beantester/gui/field_actions.py index e3112b7..2e57749 100644 --- a/beantester/gui/field_actions.py +++ b/beantester/gui/field_actions.py @@ -1,18 +1,19 @@ """Filling a Control-page field from somewhere else in the window. -Two places do this today - the right-click menu of the connection table (block -this address, leave this process alone) and the expression tester on the Tools -tab - and both must travel the SAME road into the form, or one of them will -eventually skip a step the other takes: the tk variable, the form's own copy of -the values, the dirty-state and summary refresh (``on_form_changed``), the log -line, and - during a session - the reminder that nothing reaches the engine until -the user presses Apply (convention 15). +Three places do this today - the right-click menus of the connection table and +of the socket table on the Tools tab (block this address, leave this process +alone) and the expression tester - and all must travel the SAME road into the +form, or one of them will eventually skip a step the others take: the tk +variable, the form's own copy of the values, the dirty-state and summary refresh +(``on_form_changed``), the log line, and - during a session - the reminder that +nothing reaches the engine until the user presses Apply (convention 15). It lives outside ``gui/app.py`` for the reason written in several places there: that file sits on the size ratchet in ``tests/test_code_shape.py``. And outside ``gui/pages/conns.py``, where it was born, because a tool panel importing a PAGE to reach a helper would be a sideways dependency between two things that should -not know about each other. +not know about each other. The row actions themselves (what "block this address" +MEANS) live here for the same reason: two tables offer them. """ from ..i18n import T from ..matchers import add_term @@ -38,3 +39,24 @@ def append_to_field(app, key, term, log_key): the comma escape of a regex intact and never leaves an empty term behind. """ fill_field(app, key, add_term(app.vars[key].get(), term), log_key) + + +def block_ip_address(app, ip): + """Add an address to the blocking field (decision pipeline step 2c).""" + if str(ip or "").strip(): + append_to_field(app, "block_ip", str(ip).strip(), "log.block_ip_added") + + +def leave_process_alone(app, name): + """Exclude a process from impairment by adding ``!name`` to the target. + + With a target already set this narrows it. With the target EMPTY it turns + "impair everything" into "impair everything except this one", because a bare + negative means exactly that in this expression language - and that is the case + the menu entry is really for. + """ + name = str(name or "").strip() + if not name or name == "?": + app.log(T("log.no_process_for_row")) + return + append_to_field(app, "target", f"!{name}", "log.process_excluded") diff --git a/beantester/gui/pages/conns.py b/beantester/gui/pages/conns.py index 7bfb8b7..33b8fa3 100644 --- a/beantester/gui/pages/conns.py +++ b/beantester/gui/pages/conns.py @@ -21,7 +21,6 @@ driver's own filter has been narrowed to the destination. """ -import sys import time import tkinter as tk from tkinter import ttk @@ -31,7 +30,7 @@ from ...views import (avg_packet_bytes, connection_proc, filter_connections, sort_connections, sum_traffic) from .. import dialogs -from ..field_actions import append_to_field +from ..field_actions import block_ip_address, leave_process_alone from ..model_worker import AsyncModel from ..labels import sync_note, wrapping_label from ..scaling import scaled @@ -125,33 +124,6 @@ def port_cell(port): REBUILD_MS = 1000 - -# The row actions below live on the PAGE rather than on ``App`` for a measured -# reason: ``app.py`` sits on the size ratchet in ``tests/test_code_shape.py``, which -# went red when they were added there. The road they take into the form is shared -# with the Tools tab, so it lives in ``gui/field_actions.py``; what each action -# MEANS (block this address, leave this process alone) stays here. -def block_ip_address(app, ip): - """Add an address to the blocking field (decision pipeline step 2c).""" - if str(ip or "").strip(): - append_to_field(app, "block_ip", str(ip).strip(), "log.block_ip_added") - - -def leave_process_alone(app, name): - """Exclude a process from impairment by adding ``!name`` to the target. - - With a target already set this narrows it. With the target EMPTY it turns - "impair everything" into "impair everything except this one", because a bare - negative means exactly that in this expression language - and that is the case - the menu entry is really for. - """ - name = str(name or "").strip() - if not name or name == "?": - app.log(T("log.no_process_for_row")) - return - append_to_field(app, "target", f"!{name}", "log.process_excluded") - - class ConnsPage: ID = "connections" LABEL = "app.tabs.connections" @@ -263,56 +235,10 @@ def _build_menu(self): command=self._choose_columns) self.menu.add_command(label=T("menu.reset_widths"), command=self.table.reset_widths) - self.table.tree.bind("", self._popup) - self.table.tree.bind("", self._popup) # macOS - # The same menu, reachable without a mouse. WCAG 2.1.1: anything doable - # with the pointer has to be doable from the keyboard - and this is a tool - # for testers and admins, where services.msc and the console have had - # Shift+F10 forever. - # - # The dedicated menu key is spelled DIFFERENTLY per platform - "App" on - # Windows, "Menu" on X11 - and Tk RAISES on a keysym the platform does - # not know rather than ignoring it. Binding "App" unconditionally passed - # every Windows test and killed the Linux render check, so the spelling - # is chosen here rather than tried blindly. Shift+F10 exists everywhere, - # so the keyboard route survives even if the menu key does not. - menu_key = "" if sys.platform == "win32" else "" - for sequence in ("", menu_key): - try: - self.table.tree.bind(sequence, self._popup_from_keyboard) - except tk.TclError as _exc: - # insurance, not the expected path: the spelling above is the one - # this platform should know, so a failure here is worth recording - crashlog.note(_exc, "gui.pages.conns") - - def _popup(self, event): - """Show the menu only when it has a row to act on. - - It used to pop up anywhere in the table - including an empty one - so an - empty view offered "Copy row" / "Target this process" with nothing to copy - or target. - """ - key = self.table.key_at(event.y) - if key is None: - return "break" - # select by MODEL key: the widget's item ids are recycled viewport slots, - # so they say nothing about which connection was clicked - self.table.select_keys([key]) - return self._show_menu(event.x_root, event.y_root) - - def _popup_from_keyboard(self, _event=None): - """Shift+F10 / the menu key, on whatever row is already selected. - - Nothing to position against here - there is no pointer - so the menu - opens at the table's own corner. It refuses on an empty selection for the - same reason ``_popup`` refuses on an empty table: a menu offering "Copy - row" with no row is a menu that lies. - """ - if not self.table.selected_keys(): - return "break" - tree = self.table.tree - return self._show_menu(tree.winfo_rootx() + scaled(40), - tree.winfo_rooty() + scaled(40)) + # Right click and Shift+F10 / the menu key, with the refusals on an empty + # table and an empty selection: the table's, shared with every table that + # has row actions (``SortableTree.bind_row_menu``). + self.table.bind_row_menu(self._show_menu) def _show_menu(self, x_root, y_root): # a row whose process could not be resolved (no admin rights) cannot be diff --git a/beantester/gui/theme.py b/beantester/gui/theme.py index 3acdc78..88d6189 100644 --- a/beantester/gui/theme.py +++ b/beantester/gui/theme.py @@ -132,11 +132,29 @@ # enough for the longest of the three words in the three languages (English # "Warning", bold) so the check names after it line up. The render check measures # it: a word that outgrows it is reported as clipped. +# +# The rest are the least a TABLE column may shrink to (``SortableTree(min_chars=)``, +# which widens for a longer header): `search` is the connection table's search box +# (24); `address` fits an IPv4 address with room, the connection table's remote-IP +# column (18) - an IPv6 one scrolls, the table is horizontal; `port`, `proto` and +# `process` are that table's own minimums (6, 5, 16); `tcp_state` fits the longest +# state name, SYN_RECEIVED (12). CHARS = { "help_button": 2, "value": 26, "pid": 10, "state": 8, + "search": 24, + "address": 18, + "port": 6, + "proto": 5, + "process": 16, + "tcp_state": 12, +} + +# How many rows a table shows before it scrolls: the connection table's 18. +ROWS = { + "table": 18, } diff --git a/beantester/gui/toolbox/__init__.py b/beantester/gui/toolbox/__init__.py index 65aa512..7fc2084 100644 --- a/beantester/gui/toolbox/__init__.py +++ b/beantester/gui/toolbox/__init__.py @@ -21,6 +21,7 @@ from .diagnostics import DiagnosticsPanel from .exprtest import ExprTestPanel +from .sockets import SocketsPanel class Tool(NamedTuple): @@ -33,6 +34,7 @@ class Tool(NamedTuple): # first: sockets, is-this-port-free, expression tester, adapters, diagnostics. # Each tool takes its final place the day it lands. TOOLS = ( + Tool(SocketsPanel.ID, SocketsPanel.LABEL, SocketsPanel), Tool(ExprTestPanel.ID, ExprTestPanel.LABEL, ExprTestPanel), Tool(DiagnosticsPanel.ID, DiagnosticsPanel.LABEL, DiagnosticsPanel), ) diff --git a/beantester/gui/toolbox/base.py b/beantester/gui/toolbox/base.py index ebd7ac5..802a58d 100644 --- a/beantester/gui/toolbox/base.py +++ b/beantester/gui/toolbox/base.py @@ -99,6 +99,10 @@ class Outcome(NamedTuple): error: str # "" on success, else the exception - program text elapsed_ms: int finished: float # time.time() at the end: a result says how old it is + # An i18n key for a failure the tool KNOWS and can put in words - the exception + # carries it as `user_key` (e.g. nettools.sockets.Unreadable). "" for any other + # failure, which the status line then shows as program text. + error_key: str = "" def _guarded(payload): @@ -112,13 +116,17 @@ def _guarded(payload): """ kind, work = payload started = time.perf_counter() + error_key = "" try: value, error = work(), "" except BaseException as exc: crashlog.note(exc, "gui.toolbox") value, error = None, f"{type(exc).__name__}: {exc}" + # Said in the window's language when the tool can name it; the crash log + # above keeps the whole exception either way. + error_key = str(getattr(exc, "user_key", "") or "") return Outcome(kind, value, error, round((time.perf_counter() - started) * 1000), - time.time()) + time.time(), error_key) class ToolJob: @@ -230,7 +238,10 @@ def working(self): def show(self, outcome): if outcome.error: - self.label.config(text=T("tools.common.failed", error=outcome.error), + # A failure the tool can name is said in the window's language; any + # other is shown as the program's own words, the way --doctor prints them. + error = T(outcome.error_key) if outcome.error_key else outcome.error + self.label.config(text=T("tools.common.failed", error=error), style="Status.Bad.TLabel") return at = time.strftime("%H:%M:%S", time.localtime(outcome.finished)) diff --git a/beantester/gui/toolbox/sockets.py b/beantester/gui/toolbox/sockets.py new file mode 100644 index 0000000..2e12c9e --- /dev/null +++ b/beantester/gui/toolbox/sockets.py @@ -0,0 +1,344 @@ +"""Tools tab: sockets. The rows and the search are computed in ``nettools/sockets.py``. + +Every TCP and UDP socket on the machine - listeners, open connections, the ones +closing - with its state and its process, without a session: the question "which +port does my application listen on?" comes BEFORE anything is impaired, and the +connection table only knows traffic that crossed the driver. + +A snapshot, read when the tab is first looked at and then when asked (the owner's +decision, 2026-09-23): a table that re-read itself every tick would move under the +pointer, and at a hundred thousand sockets each read is a second of work. + +One request at a time. The worker keeps only the LAST request that waits +(``gui/model_worker.py``), and a search queued behind a read would have been +computed on the table it was queued against - the old one - and shown after the +new. So nothing is queued: while the worker is busy the inputs are only +remembered, and each answer that lands is compared with them and asked again +when they moved (``_on_outcome``). +""" +import time +import tkinter as tk +from tkinter import ttk + +from ...i18n import T +from ...nettools import sockets +from .. import dialogs +from ..field_actions import block_ip_address, leave_process_alone +from ..labels import wrapping_label +from ..theme import CHARS, ROWS, space, style_menu +from ..tooltip import add_tooltip +from ..widgets import SortableTree +from .base import Debounce, Poller, StatusLine, job, remembered +from ... import crashlog + +AREA = "gui.toolbox.sockets" + +# Column -> header key. The connection table's headers where the meaning is the +# same; the tooltips are this table's own - that table's speak of captured packets. +# The ORDER is the connection table's too, process first: the table scrolls +# sideways, and the first render (760 px) put PID and process - the answer to "who +# holds this port?" - past the right edge. The remote end, empty on every listener, +# goes last. +COLUMNS = {"proc": "conns.process", "pid": "conns.pid", "proto": "conns.proto", + "local_ip": "tools.sockets.col.local_ip", "local_port": "conns.local_port", + "state": "tools.sockets.col.state", "remote_ip": "conns.remote_ip", + "remote_port": "conns.remote_port"} +TIPS = {column: f"tips.tools_sockets_col_{column}" for column in COLUMNS} +NUMERIC = frozenset({"local_port", "remote_port", "pid"}) +# Centred for the connection table's reason: each follows a right-aligned number, +# and left-aligned it touched it - the first render read "22 LISTEN" as one value. +CENTERED = frozenset({"proto", "state"}) +MIN_CHARS = {"proto": CHARS["proto"], "local_ip": CHARS["address"], + "local_port": CHARS["port"], "remote_ip": CHARS["address"], + "remote_port": CHARS["port"], "state": CHARS["tcp_state"], + "pid": CHARS["pid"], "proc": CHARS["process"]} + +READ, VIEW = "read", "view" + +# Menu entries a row may be refused, by position (0 is "Copy", 1 the separator). +NEEDS_NAME = (2, 3) # target / leave alone: the process's name +NEEDS_REMOTE = (4, 5) # limit to / block: an address on the other end + + +def render(s): + """One row's cells, in ``COLUMNS`` order. Called only for the rows on screen.""" + return (s.proc, "" if s.pid is None else s.pid, s.proto, s.local_ip, s.local_port, + s.state, s.remote_ip, "" if s.remote_port is None else s.remote_port) + + +def address(s): + """The remote address as a Control field takes it: without an IPv6 zone.""" + return s.remote_ip.split("%")[0] + + +class SocketsPanel: + ID = "sockets" + LABEL = "tools.sockets.tab" + + def __init__(self, app, parent): + self.app = app + self.job = job(app, self.ID) + self.memory = remembered(app, self.ID) + self.frame = ttk.Frame(parent) + self.debounce = Debounce(self.frame, self._search) + self.query = tk.StringVar(value=self.memory.get("query", "")) + + bar = ttk.Frame(self.frame) + bar.pack(fill="x", padx=space("page"), pady=(space("row"), 0)) + ttk.Label(bar, text=T("fields.search")).pack(side="left") + self.entry = ttk.Entry(bar, textvariable=self.query, width=CHARS["search"]) + self.entry.pack(side="left", padx=(space("tight"), space("inline"))) + self.entry.bind("", self._typed) + self.entry.bind("", self.debounce.now) + self.entry.bind("", self._clear) + add_tooltip(self.entry, "tips.tools_sockets_search", shortcut="Ctrl+F") + dialogs.help_button(bar, app.root, "tools.sockets.help_title", + "tools.sockets.help_body", "tips.tools_sockets_help").pack( + side="left", padx=(0, space("inline"))) + self.refresh_btn = ttk.Button(bar, text=T("tools.sockets.refresh"), command=self.read) + self.refresh_btn.pack(side="left") + add_tooltip(self.refresh_btn, "tips.tools_sockets_refresh") + self.count = ttk.Label(bar, text="", style="Muted.TLabel") + self.count.pack(side="right") + + self.status = StatusLine(self.frame) + self.status.label.pack(fill="x", padx=space("page"), pady=(space("row"), 0)) + # What the rows on screen are missing, or how old they are. + self.note = wrapping_label(self.frame, "") + self.note.pack(fill="x", padx=space("page"), pady=(space("hair"), 0)) + + holder = ttk.Frame(self.frame) + holder.pack(fill="both", expand=True, padx=space("page"), + pady=(space("tight"), space("page"))) + column, reverse = self._sort() + self.table = SortableTree(holder, COLUMNS, sort={"col": column, "reverse": reverse}, + on_sort=self._on_sort, height=ROWS["table"], + horizontal=True, min_chars=MIN_CHARS, tips=TIPS, + numeric=NUMERIC, centered=CENTERED, + empty_text="tools.common.working") + self._build_menu() + + self.poller = Poller(self.frame, self.job, self._on_outcome) + # What this window already knows is shown as it is. The FIRST read waits for + # the tab to be on screen (refresh / pending): every page is built when the + # window opens, and this tab's first tool with it. + latest = self._latest() + if latest is not None: + self._show(latest) + read = self.job.last.get(READ) + if read is not None: + self.status.show(read) + if read.error: + self._show_failure() + if self.job.busy(): + self.status.working() + self.poller.start() + elif latest is not None and not self._answers_the_inputs(latest): + # typed inside the pause before a rebuild: remembered, never searched + self._ask(VIEW) + self._sync_buttons() + + def _build_menu(self): + self.menu = style_menu(tk.Menu(self.frame, tearoff=0)) + self.menu.add_command(label=T("menu.copy_row"), command=self._copy_rows) + self.menu.add_separator() + self.menu.add_command(label=T("menu.target_process"), command=self._target) + self.menu.add_command(label=T("menu.leave_process_alone"), command=self._leave_alone) + self.menu.add_command(label=T("menu.limit_dest"), command=self._limit) + self.menu.add_command(label=T("menu.block_ip"), command=self._block) + self.table.bind_row_menu(self._show_menu) + + # -- the page calls ------------------------------------------------------ # + def refresh(self): + """The tick, while this tab is on screen: the first look reads.""" + self._read_if_never() + self._sync_buttons() + + def pending(self): + """Take an answer that has arrived, and say whether one is still due. + + The GUI render check calls this until it says no - which is also a look at + the tab, so it starts the first read the way a tick would. + """ + self._read_if_never() + return self.poller.now() + + def teardown(self): + self.poller.cancel() + self.debounce.cancel() + + def focus_search(self): + """Ctrl+F while this tool is on screen: its own search box.""" + with crashlog.quiet(AREA): + self.entry.focus_set() + self.entry.select_range(0, "end") + return "break" + + # -- asking -------------------------------------------------------------- # + def read(self): + """Read the machine again (the Refresh button).""" + if not self.job.busy(): + self._ask(READ) + + def _read_if_never(self): + # Not after a read that failed: that says so, and waits for Refresh rather + # than asking the system again on every tick. + if READ not in self.job.last and not self.job.busy(): + self._ask(READ) + + def _ask(self, kind): + query, (column, reverse) = self.query.get(), self._sort() + if kind == READ: + self.job.run(READ, lambda: sockets.table(None, query, column, reverse)) + self.status.working() + else: + # Taken HERE, on the UI thread: the worker does not reach back into the + # window (convention 26), and nothing reads while this runs. + latest = self._latest() + if latest is None: + return + snapshot = latest.snapshot + self.job.run(VIEW, lambda: sockets.table(snapshot, query, column, reverse)) + self._sync_buttons() + self.poller.start() + + def _asked_again(self): + """A view for the inputs as they are now, unless one is on its way.""" + if not self.job.busy(): + self._ask(VIEW) + + def _typed(self, _event=None): + # Remembered at every key, not when the pause runs out: a language change + # inside that pause rebuilds the panel and would lose the last characters. + self.memory["query"] = self.query.get() + self.debounce() + + def _search(self): + self.memory["query"] = self.query.get() + self._asked_again() + + def _clear(self, _event=None): + self.query.set("") + self._typed() + self.debounce.now() + + def _on_sort(self, sort): + self.memory["sort"] = sort["col"] + self.memory["reverse"] = "1" if sort["reverse"] else "" + self._asked_again() + + def _sort(self): + column = self.memory.get("sort", sockets.DEFAULT_SORT[0]) + if column not in COLUMNS: + column = sockets.DEFAULT_SORT[0] + return column, bool(self.memory.get("reverse", "")) + + # -- answers ------------------------------------------------------------- # + def _latest(self): + """The newest view this window has, whichever kind of work made it.""" + views = list(self.job.value.values()) + return max(views, key=lambda v: v.made_at) if views else None + + def _on_outcome(self, outcome): + if outcome.kind == READ: + self.status.show(outcome) + if outcome.error: + self._show_failure() + else: + self._show(outcome.value) + # Typed or sorted while the worker was busy: those inputs were remembered, + # not sent (see the module docstring), so this answer may be for older ones. + latest = self._latest() + if latest is not None and not self._answers_the_inputs(latest): + self._asked_again() + self._sync_buttons() + + def _answers_the_inputs(self, view): + return (view.query, view.sort) == (self.query.get(), self._sort()) + + def _show(self, view): + snapshot = view.snapshot + if not snapshot.sockets: + empty = "tools.sockets.empty" + else: + empty = "tools.sockets.empty_match" + self.table.set_empty_text(empty) + self.table.set_model(view.rows, render=render, key_of=lambda s: s.key) + self.count.config(text=T("conns.shown_of", shown=len(view.rows), + total=len(snapshot.sockets))) + # Asked of the job, not remembered by the panel: a search or a sort after a + # failed Refresh shows the same old rows, and they must still say how old. + self.note.config(text="\n".join(self._notes(snapshot, stale=self._read_failed()))) + + def _read_failed(self): + """The newest read failed, so the rows on screen are older than the last try.""" + read = self.job.last.get(READ) + return bool(read is not None and read.error) + + def _notes(self, snapshot, stale=False): + lines = [] + if stale: + at = time.strftime("%H:%M:%S", time.localtime(snapshot.read_at)) + lines.append(T("tools.sockets.note_stale", time=at)) + if snapshot.failed: + lines.append(T("tools.sockets.note_failed", tables=", ".join(snapshot.failed))) + unnamed = sockets.without_owner(snapshot) + if unnamed: + lines.append(T("tools.sockets.note_no_pid", count=unnamed)) + return lines + + def _show_failure(self): + """The read failed: say so, and keep the last good rows with their age.""" + latest = self._latest() + if latest is None: + self.table.set_empty_text("tools.sockets.empty_failed") + self.table.set_model([], render=render, key_of=lambda s: s.key) + self.note.config(text="") + return + self.note.config(text="\n".join(self._notes(latest.snapshot, stale=True))) + + def _sync_buttons(self): + self.refresh_btn.state(["disabled"] if self.job.busy() else ["!disabled"]) + + # -- the menu on a row ---------------------------------------------------- # + def _selected(self): + keys = self.table.selected_keys() + return self.table.item_for_key(keys[0]) if keys else None + + def _show_menu(self, x_root, y_root): + s = self._selected() + named, remote = bool(s and s.proc), bool(s and s.remote_ip) + for indexes, allowed in ((NEEDS_NAME, named), (NEEDS_REMOTE, remote)): + for index in indexes: + with crashlog.quiet(AREA): + self.menu.entryconfigure(index, state="normal" if allowed else "disabled") + try: + self.menu.tk_popup(x_root, y_root) + finally: + with crashlog.quiet(AREA): + self.menu.grab_release() + + def _copy_rows(self): + text = self.table.copy_text() + if text: + self.app.copy_to_clipboard(text) + + def _target(self): + s = self._selected() + if s and s.proc: + self.app.set_target_expression(s.proc) + + def _leave_alone(self): + s = self._selected() + if s: + leave_process_alone(self.app, s.proc) + + def _limit(self): + s = self._selected() + if s and s.remote_ip: + self.app.set_destination(address(s), str(s.remote_port or "")) + + def _block(self): + s = self._selected() + if s and s.remote_ip: + block_ip_address(self.app, address(s)) diff --git a/beantester/gui/widgets/sortable_tree.py b/beantester/gui/widgets/sortable_tree.py index 4c771cc..b4bab69 100644 --- a/beantester/gui/widgets/sortable_tree.py +++ b/beantester/gui/widgets/sortable_tree.py @@ -50,7 +50,7 @@ at a glance). """ import sys -from tkinter import ttk +from tkinter import TclError, ttk from ...i18n import T from ..scaling import column_width, scaled @@ -180,6 +180,7 @@ def __init__(self, parent, columns, sort=None, on_sort=None, self._slot_keys = [] # slot index -> model key currently shown self._selected = [] # selected MODEL KEYS (survive a repaint) self._painted = {} # slot iid -> (values, tags) last written + self._show_row_menu = None # the page's menu, once bind_row_menu is called self._height = max(1, int(height)) if horizontal and stretch: @@ -561,6 +562,72 @@ def selection_values(self): rows = self.selected_rows() return rows[0] if rows else None + # -- a menu on a row -------------------------------------------------------- # + def bind_row_menu(self, show): + """Call ``show(x_root, y_root)`` for a menu on a row: right click or keyboard. + + Every table with row actions needs the same two ways in and the same two + refusals, so they live with the table rather than with each page that has + a menu. The page keeps what is its own - the entries, and which of them a + row may use - in ``show``, which is called only when there IS a row. + + The keyboard way exists because anything doable with the pointer has to be + doable from the keyboard (WCAG 2.1.1) - and this is a tool for testers and + admins, where services.msc and the console have had Shift+F10 forever. + + The dedicated menu key is spelled DIFFERENTLY per platform - "App" on + Windows, "Menu" on X11 - and Tk RAISES on a keysym the platform does not + know rather than ignoring it. Binding "App" unconditionally passed every + Windows test and killed the Linux render check, so the spelling is chosen + here rather than tried blindly. Shift+F10 exists everywhere, so the + keyboard route survives even if the menu key does not. + """ + self._show_row_menu = show + # The virtual event, not the buttons: Tk maps it per platform - the right + # button on Windows and X11, Button-2 on macOS (library/tk.tcl, 8.6 and 9). + # Binding by hand opened the menu on a MIDDLE click everywhere else. + self.tree.bind("<>", self.row_menu_at_pointer) + menu_key = "" if self._platform == "win32" else "" + for sequence in ("", menu_key): + try: + self.tree.bind(sequence, self.row_menu_from_keyboard) + except TclError as _exc: + # insurance, not the expected path: the spelling above is the one + # this platform should know, so a failure here is worth recording + crashlog.note(_exc, "gui.widgets.sortable_tree") + + def row_menu_at_pointer(self, event): + """Right click: select the row under the pointer and open the menu on it. + + It used to pop up anywhere in the table - including an empty one - so an + empty view offered "Copy row" with nothing to copy. Selected by MODEL key: + the widget's item ids are recycled viewport slots, so they say nothing + about which row was clicked. + """ + key = self.key_at(event.y) + if key is None: + return "break" + self.select_keys([key]) + return self._open_row_menu(event.x_root, event.y_root) + + def row_menu_from_keyboard(self, _event=None): + """Shift+F10 / the menu key, on whatever row is already selected. + + Nothing to position against here - there is no pointer - so the menu opens + at the table's own corner. It refuses on an empty selection for the same + reason the pointer refuses on an empty table: a menu offering "Copy row" + with no row is a menu that lies. + """ + if not self.selected_keys(): + return "break" + return self._open_row_menu(self.tree.winfo_rootx() + scaled(40), + self.tree.winfo_rooty() + scaled(40)) + + def _open_row_menu(self, x_root, y_root): + if self._show_row_menu is not None: + self._show_row_menu(x_root, y_root) + return "break" + # -- header tooltips ------------------------------------------------------- # def _column_at(self, x): """Column id under the pointer, or None. diff --git a/beantester/nettools/sockets.py b/beantester/nettools/sockets.py new file mode 100644 index 0000000..ac925f0 --- /dev/null +++ b/beantester/nettools/sockets.py @@ -0,0 +1,196 @@ +"""The socket table: every TCP and UDP socket on this machine, with its state and +its process - what ``netstat -ano`` shows, and without a session. + +The connection table answers only once a session runs, and only about traffic +that crossed the driver: a program that listens and has not been spoken to yet is +invisible to it. "Which port does my application listen on before I break +anything?" and "who is holding 8080?" are answered here. + +The rows come from ``portmap.socket_rows`` - the one module that asks the system +for sockets - and the names from ONE process snapshot taken right after it. The +search is the connection table's (``views.compile_query``) with this table's +columns, so the window has one search language (convention 10). Nothing here +draws or translates. +""" +import ipaddress +import time +from typing import NamedTuple + +from .. import portmap +from ..matchers import KIND_INT, KIND_IP, KIND_PROCESS +from ..views import compile_query + +# What the table sorts by until the person clicks a header: "which port" is the +# question it is opened with. +DEFAULT_SORT = ("local_port", False) + + +class Socket(NamedTuple): + """One row of the table: a ``portmap.SocketRow`` with its process named.""" + key: str # unique within a read (see _sockets) + proto: str + family: int + local_ip: str + local_port: int + remote_ip: str + remote_port: int | None + state: str + pid: int | None + proc: str # "" when no process owns it or it could not be named + + +class Snapshot(NamedTuple): + """One read of the machine.""" + sockets: tuple + failed: tuple # the tables that did not answer ("tcp/v6"...) + read_at: float # time.time() when the read finished + elapsed_ms: int + + +class View(NamedTuple): + """A snapshot as the person asked to see it.""" + snapshot: Snapshot + rows: list # the sockets the query keeps, in the sort's order + query: str + sort: tuple # (column, reverse) + made_at: float # time.time(): the newest view is the one shown + + +class Unreadable(Exception): + """No socket table could be read, for a reason the person can act on. + + ``user_key`` names the reason in the window's language (``gui/toolbox/base.py`` + shows it); the message stays the program's own words, for the crash log. + """ + + def __init__(self, user_key, detail): + super().__init__(detail) + self.user_key = user_key + + +# ``portmap.SocketTableUnavailable.reason`` -> what the person is told. +UNREADABLE_KEYS = {"denied": "tools.sockets.error_denied", + "missing": "tools.sockets.error_missing"} + + +def read(): + """Read every socket and name its process. Runs on a worker; may raise. + + ``Unreadable`` when no table can be read at all: an empty table would say "no + sockets", which nobody here could know. Anything else that fails is the + program's own fault and travels as it is. + """ + started = time.perf_counter() + try: + rows, failed = portmap.socket_rows() + except portmap.SocketTableUnavailable as exc: + raise Unreadable(UNREADABLE_KEYS.get(exc.reason, ""), str(exc)) from exc + # Named AFTER the table is read, so a process that exited in between leaves + # its rows without a name - which is true - instead of the table outliving the + # names it was given. A PID reused inside those few milliseconds is the one + # case left: named after its new owner. Accepted; it cannot be told apart. + names = portmap.process_names() + return Snapshot(tuple(_sockets(rows, names)), tuple(failed), time.time(), + round((time.perf_counter() - started) * 1000)) + + +def _sockets(rows, names): + """``Socket`` per row, each with a key no other row of this read has. + + Two rows can be identical in every column: one process may hold two UDP + sockets on the same address and port (SO_REUSEADDR). The table finds a row by + its key through a dict, so a duplicate key would send a click on one to the + other - the numbering of repeats keeps them apart. + """ + seen = {} + for row in rows: + base = (f"{row.proto}|{row.local_ip}|{row.local_port}|{row.remote_ip}|" + f"{row.remote_port}|{row.pid}") + repeat = seen.get(base, 0) + seen[base] = repeat + 1 + # PID 0 owns nothing: the snapshot calls it "[System Process]", and a row + # in TIME_WAIT is not that process's socket. + name = names.get(row.pid, "") if row.pid else "" + yield Socket(f"{base}|{repeat}", row.proto, row.family, row.local_ip, + row.local_port, row.remote_ip, row.remote_port, row.state, + row.pid, name) + + +def without_owner(snapshot): + """How many sockets the system would not name a process for (another account's).""" + return sum(1 for socket in snapshot.sockets if socket.pid is None) + + +# -- search ------------------------------------------------------------------------ # +# The connection table's qualifiers where the meaning is the same - `ip:` and +# `port:` are the REMOTE end there and here - plus what only this table has: the +# local address and the TCP state. +SEARCH_FIELDS = { + "proc": (KIND_PROCESS, lambda s, m: (s.pid, s.proc)), + "pid": (KIND_INT, lambda s, m: s.pid), + "proto": (KIND_PROCESS, lambda s, m: s.proto), + "state": (KIND_PROCESS, lambda s, m: s.state), + "ip": (KIND_IP, lambda s, m: s.remote_ip or None), + "port": (KIND_INT, lambda s, m: s.remote_port), + "lip": (KIND_IP, lambda s, m: s.local_ip), + "lport": (KIND_INT, lambda s, m: s.local_port), +} + + +def _blob(s, _m): + """What plain text is searched in: every column, the way it is shown.""" + remote_port = "" if s.remote_port is None else s.remote_port + pid = "" if s.pid is None else s.pid + return (f"{s.proc} {s.proto} {s.state} {s.local_ip}:{s.local_port} " + f"{s.remote_ip}:{remote_port} {pid}").lower() + + +def _ip_key(text, cache): + """An address as something that sorts by number: v4 before v6, then the value.""" + if not text: + return None + key = cache.get(text) + if key is None: + address = ipaddress.ip_address(text.split("%")[0]) + key = cache[text] = (address.version, int(address)) + return key + + +# Per column, the value to sort by. None sorts LAST in both directions: an empty +# cell is not the smallest value, it is no value. +_SORT_KEYS = { + "proto": lambda s, c: (s.proto, s.family), + "local_ip": lambda s, c: _ip_key(s.local_ip, c), + "local_port": lambda s, c: s.local_port, + "remote_ip": lambda s, c: _ip_key(s.remote_ip, c), + "remote_port": lambda s, c: s.remote_port, + "state": lambda s, c: s.state or None, + "pid": lambda s, c: s.pid, + "proc": lambda s, c: s.proc.lower() or None, +} +COLUMNS = tuple(_SORT_KEYS) + + +def sort(sockets, column, reverse=False): + """``sockets`` in the order of one column, empty cells last. Stable.""" + key = _SORT_KEYS.get(column, _SORT_KEYS[DEFAULT_SORT[0]]) + cache = {} + keyed = [(key(s, cache), s) for s in sockets] + present = [pair for pair in keyed if pair[0] is not None] + present.sort(key=lambda pair: pair[0], reverse=reverse) + return [s for _k, s in present] + [s for k, s in keyed if k is None] + + +def view(snapshot, query="", column=DEFAULT_SORT[0], reverse=False): + """The sockets ``query`` keeps, sorted. The search box's rules: see views.py.""" + tests = compile_query(query, fields=SEARCH_FIELDS, blob=_blob, bools=()) + kept = [s for s in snapshot.sockets if all(t(s, None) for t in tests)] + return sort(kept, column, reverse) + + +def table(snapshot, query, column, reverse): + """The worker's whole job: read (when ``snapshot`` is None), then filter and sort.""" + if snapshot is None: + snapshot = read() + return View(snapshot, view(snapshot, query, column, reverse), query, + (column, reverse), time.time()) diff --git a/beantester/portmap.py b/beantester/portmap.py index 072b217..230ae94 100644 --- a/beantester/portmap.py +++ b/beantester/portmap.py @@ -22,10 +22,18 @@ Nothing here raises: a lookup that cannot be answered returns ``None`` / ``""``, because the callers sit in the capture loop. + +The one exception is :func:`socket_rows`, the WHOLE socket table for the Tools +tab: every row, listeners and closing sockets included, read on a click by a +worker thread and never by the packet path. It raises +:class:`SocketTableUnavailable` when nothing can be read at all, because the +person looking at an empty table must be told it is not an empty machine. """ +import ipaddress import sys import threading import time +from typing import NamedTuple from . import crashlog @@ -65,16 +73,112 @@ def _swap16(value): return ((value & 0xFF) << 8) | ((value >> 8) & 0xFF) +# -- the whole socket table (the Tools tab) ------------------------------------ # +class SocketRow(NamedTuple): + """One socket, as the Tools tab shows it. Built on a worker, never on the packet path.""" + proto: str # "TCP" / "UDP" + family: int # 4 / 6 + local_ip: str + local_port: int + remote_ip: str # "" for UDP and for a listener (see _tcp4_row) + remote_port: int | None + state: str # a TCP_STATES name; "" for UDP + pid: int | None # 0: owned by no process (TIME_WAIT); None: the OS would not say + + +class SocketTableUnavailable(Exception): + """No socket table could be read at all. ``reason``: "denied" or "missing".""" + + def __init__(self, reason, detail=""): + super().__init__(detail or reason) + self.reason = reason + + +# MIB_TCP_STATE (tcpmib.h), numbered as Microsoft Learn documents it for +# MIB_TCPROW_OWNER_PID and MIB_TCP6ROW_OWNER_PID, named the way RFC 793 and +# Windows' own netstat spell them. psutil spells four of them its own way +# (_PSUTIL_STATES), so both paths end in the same words and one search finds both. +TCP_STATES = {1: "CLOSED", 2: "LISTEN", 3: "SYN_SENT", 4: "SYN_RECEIVED", + 5: "ESTABLISHED", 6: "FIN_WAIT_1", 7: "FIN_WAIT_2", 8: "CLOSE_WAIT", + 9: "CLOSING", 10: "LAST_ACK", 11: "TIME_WAIT", 12: "DELETE_TCB"} +_PSUTIL_STATES = {"SYN_RECV": "SYN_RECEIVED", "FIN_WAIT1": "FIN_WAIT_1", + "FIN_WAIT2": "FIN_WAIT_2", "CLOSE": "CLOSED"} + + +def _tcp_state(number): + """A state's name; a number Learn does not list is shown as the number itself.""" + number = int(number) + return TCP_STATES.get(number, str(number)) + + +def _ipv4(value): + """A DWORD holding an ``in_addr``: the first octet is the LOW byte.""" + return f"{value & 0xFF}.{(value >> 8) & 0xFF}.{(value >> 16) & 0xFF}.{(value >> 24) & 0xFF}" + + +def _ipv6(raw, scope): + """16 bytes in network order, plus the scope for a link-local address. + + The scope id is taken AS IT IS. Learn says it is in network byte order; it is + not - MEASURED 2026-09-23 (Win11): a UDP socket bound to fe80::...%12 has + 12 in its row, and the byte-swapped value would be 201326592. + """ + text = str(ipaddress.IPv6Address(bytes(raw))) + return f"{text}%{int(scope)}" if scope else text + + +def _tcp4_row(row): + # A listener's remote half "has no meaning" (Learn, MIB_TCPROW_OWNER_PID), so + # it is left empty rather than shown as the 0.0.0.0:0 the row happens to hold. + state = _tcp_state(row.dwState) + idle = state == "LISTEN" + return SocketRow("TCP", 4, _ipv4(row.dwLocalAddr), _swap16(row.dwLocalPort & 0xFFFF), + "" if idle else _ipv4(row.dwRemoteAddr), + None if idle else _swap16(row.dwRemotePort & 0xFFFF), + state, int(row.dwOwningPid)) + + +def _tcp6_row(row): + state = _tcp_state(row.dwState) + idle = state == "LISTEN" + return SocketRow("TCP", 6, _ipv6(row.ucLocalAddr, row.dwLocalScopeId), + _swap16(row.dwLocalPort & 0xFFFF), + "" if idle else _ipv6(row.ucRemoteAddr, row.dwRemoteScopeId), + None if idle else _swap16(row.dwRemotePort & 0xFFFF), + state, int(row.dwOwningPid)) + + +def _udp4_row(row): + return SocketRow("UDP", 4, _ipv4(row.dwLocalAddr), _swap16(row.dwLocalPort & 0xFFFF), + "", None, "", int(row.dwOwningPid)) + + +def _udp6_row(row): + return SocketRow("UDP", 6, _ipv6(row.ucLocalAddr, row.dwLocalScopeId), + _swap16(row.dwLocalPort & 0xFFFF), "", None, "", + int(row.dwOwningPid)) + + +_ROW_CONVERTERS = {("tcp", _AF_INET): _tcp4_row, ("tcp", _AF_INET6): _tcp6_row, + ("udp", _AF_INET): _udp4_row, ("udp", _AF_INET6): _udp6_row} + + # -- native (Windows) --------------------------------------------------------- # class _Native: - """ctypes bindings for the two extended socket tables. Windows only.""" + """ctypes bindings for the two extended socket tables. Windows only. + + ``iphlpapi`` is injectable so the buffer walk can be tested on any platform: + ``ctypes.wintypes`` imports on Linux too (MEASURED on CPython 3.14, where its + DWORD is 8 bytes), and a fake that writes the table with these same structures + reads back consistently. The layout itself is what Windows proves. + """ - def __init__(self): + def __init__(self, iphlpapi=None): import ctypes from ctypes import wintypes self.ctypes = ctypes - self.iphlpapi = ctypes.WinDLL("iphlpapi.dll") + self.iphlpapi = iphlpapi if iphlpapi is not None else ctypes.WinDLL("iphlpapi.dll") class MIB_TCPROW_OWNER_PID(ctypes.Structure): _fields_ = [("dwState", wintypes.DWORD), @@ -119,6 +223,33 @@ def _table(self, proto, family, out, owners=None): ``owners`` (optional) collects ``port -> {pid, ...}`` for the ports where more than one row claims the same number, which ``out`` cannot represent. """ + fetched = self._fetch(proto, family) + if fetched is None: + return False + _buffer, rows = fetched # held while the rows are read (see _fetch) + for row in rows: + port = _swap16(row.dwLocalPort & 0xFFFF) + pid = int(row.dwOwningPid) + if port and pid: + # LAST ROW WINS, and that is unchanged - see port_pid_map for what + # it costs and _put for how the discarded owner is remembered. + _put(out, owners, port, pid) + return True + + def _fetch(self, proto, family): + """One table as ``(buffer, rows)``, or ``None`` when it would not answer. + + Shared by the capture-side map (``_table``) and the Tools tab's full table + (``socket_rows``): one buffer walk, so a fix to it reaches both. + + 🔴 The BUFFER comes back with the rows, and a caller keeps it referenced for + as long as it reads them. ``rows`` is a ctypes VIEW over the buffer's + memory; while this code lived inside one function the buffer was a local + beside it and the question never came up. Handed out alone, the view would + keep its memory alive only through ctypes' internal ``_objects`` chain - + and a view over freed memory is a crash in the module the packet path + leans on. An explicit reference costs nothing and depends on nothing. + """ import ctypes from ctypes import wintypes @@ -146,27 +277,41 @@ def _table(self, proto, family, out, owners=None): self._sizes[(proto, family)] = size.value break if rc != _ERROR_INSUFFICIENT_BUFFER: - return False + return None else: - return False + return None count = ctypes.cast(buffer, ctypes.POINTER(wintypes.DWORD))[0] if not count: - return True + return buffer, () rows = ctypes.cast( ctypes.byref(buffer, ctypes.sizeof(wintypes.DWORD)), ctypes.POINTER(row_type * count)).contents - for row in rows: - port = _swap16(row.dwLocalPort & 0xFFFF) - pid = int(row.dwOwningPid) - if port and pid: - # LAST ROW WINS, and that is unchanged - see port_pid_map for what - # it costs and _put for how the discarded owner is remembered. - _put(out, owners, port, pid) - return True + return buffer, rows FAMILY_NAMES = {_AF_INET: "v4", _AF_INET6: "v6"} + def socket_rows(self): + """Every row of the four tables as ``SocketRow``, and the tables that failed. + + Unlike ``port_pid_map`` nothing is dropped: the row with PID 0 is a socket + in TIME_WAIT, which no process owns any more (measured on a loopback + connection closed a moment earlier), and a port several processes hold is + several rows. The capture side skips those because it needs one owner per + port; a person asking "who holds 8080?" needs all of them. + """ + rows, failed = [], [] + for proto in ("tcp", "udp"): + for family in (_AF_INET, _AF_INET6): + fetched = self._fetch(proto, family) + if fetched is None: + failed.append(f"{proto}/{self.FAMILY_NAMES[family]}") + continue + _buffer, table = fetched # held while the rows are read + convert = _ROW_CONVERTERS[(proto, family)] + rows.extend(convert(row) for row in table) + return rows, failed + def port_pid_map(self, owners=None): """``{local port: pid}`` from all four socket tables, or ``None``. @@ -252,6 +397,75 @@ def _psutil_port_pid_map(owners=None): return None +def _psutil_row(proto, conn): + """One psutil connection as a ``SocketRow``, in the native path's words.""" + laddr = conn.laddr or ("", 0) + raddr = conn.raddr or () + state = _PSUTIL_STATES.get(conn.status, conn.status) if proto == "TCP" else "" + blank = state == "LISTEN" or not raddr + local_ip = str(laddr[0]) + return SocketRow(proto, 6 if ":" in local_ip else 4, local_ip, int(laddr[1]), + "" if blank else str(raddr[0]), None if blank else int(raddr[1]), + state, None if conn.pid is None else int(conn.pid)) + + +def _psutil_socket_rows(): + """Every TCP and UDP socket through psutil (off Windows, or the native path failed). + + Raises instead of answering empty: see :func:`socket_rows`. Another account's + socket may carry no PID - psutil leaves ``pid`` as None when the OS will not + say, which on Linux is any socket that is not ours unless we run as root. + """ + try: + import psutil + except ImportError: + raise SocketTableUnavailable( + "missing", "there is no socket table to read here: psutil is not installed" + ) from None + try: + found = [("TCP", conn) for conn in psutil.net_connections(kind="tcp")] + found += [("UDP", conn) for conn in psutil.net_connections(kind="udp")] + except psutil.AccessDenied as exc: + raise SocketTableUnavailable( + "denied", "the system refused to list its sockets - reading them may need " + f"administrator rights ({exc})") from exc + return [_psutil_row(proto, conn) for proto, conn in found] + + +def socket_rows(): + """``(rows, failed)``: every TCP and UDP socket on this machine, one row each. + + For the Tools tab, read on a click by a worker; the packet path never comes + here (``tests/test_hot_path.py`` watches both routes). A NEW ``_Native`` per + call, never ``default_table()``'s: nothing is shared with the capture side - + not the size hints, and not the switch that retires a broken native path for + the rest of a session. + + ``failed`` names the tables that would not answer when the others did; the + rows are still the rows. When none answers, psutil is asked; when that cannot + answer either, :class:`SocketTableUnavailable` - an empty list would read as + "no sockets", which is a claim about the machine this code cannot make. + """ + native = _make_native() + if native is not None: + rows, failed = native.socket_rows() + if len(failed) < len(_ROW_CONVERTERS): + return rows, failed + return _psutil_socket_rows(), [] + + +def process_names(): + """``{pid: name}`` for every process, from ONE snapshot, touching no cache. + + The Tools tab names its rows this way and not through ``PortTable.info``: + writing into that cache would change what targeting reads (entries from a + snapshot carry no start time, so they are checked by age instead). The + snapshot names every process WITHOUT opening it - MEASURED 2026-09-23 without + administrator rights: all 38 PIDs owning a socket named, ``System`` included. + """ + return {pid: entry[0] for pid, entry in _process_table().items()} + + def _psutil_process_table(): """``{pid: (name, ppid, created)}`` for every process (the slow, portable path).""" try: diff --git a/beantester/views.py b/beantester/views.py index dfa790c..a827b59 100644 --- a/beantester/views.py +++ b/beantester/views.py @@ -126,9 +126,17 @@ def _connection_blob(c, proc_map=None): f"{c.get('local_port') or ''}").lower() -def compile_query(query): +def compile_query(query, fields=None, blob=None, bools=None): """Turn a search string into a list of predicates, ONCE per query. + The connection table's by default. ``fields``, ``blob`` and ``bools`` make it + any table's: the socket table on the Tools tab (``nettools/sockets.py``) passes + its own columns and gets the same language, the same parser and the same + tolerance of half-typed terms - one search syntax in the window, not two + (convention 10). ``fields`` maps a qualifier to ``(kind, getter)``, where the + getter of ``proc`` returns ``(pid, name)``, which the process kind judges + together; ``None`` there is the connection table's own reading of a row. + Compiling per row would put the expression parser on the path of every one of a hundred thousand rows on every search, so parse here and return closures. The row loop then only calls them. @@ -152,15 +160,18 @@ def compile_query(query): nothing until it becomes valid, and a term naming an unknown field falls back to plain text - `http://x` is a URL someone pasted, not a field called `http`. """ + fields = SEARCH_FIELDS if fields is None else fields + blob = _connection_blob if blob is None else blob + bools = BOOL_FIELDS if bools is None else bools tests = [] for raw in str(query or "").split(): field, sep, value = raw.partition(":") field = field.lower() - if not sep or (field not in SEARCH_FIELDS and field not in BOOL_FIELDS): + if not sep or (field not in fields and field not in bools): text = raw.lower() - tests.append(lambda c, m, t=text: t in _connection_blob(c, m)) + tests.append(lambda c, m, t=text, b=blob: t in b(c, m)) continue - if field in BOOL_FIELDS: + if field in bools: want = value.strip().lower() if want in TRUE_WORDS: tests.append(lambda c, m, k=field: bool(c.get(k))) @@ -169,7 +180,7 @@ def compile_query(query): else: # half-typed: match nothing yet tests.append(lambda c, m: False) continue - kind, getter = SEARCH_FIELDS[field] + kind, getter = fields[field] try: matcher = parse_matcher(value, kind, f"fields.{field}", bounds=_BOUNDS.get(field)) @@ -178,7 +189,9 @@ def compile_query(query): continue if not matcher: # `port:` with nothing after it continue - if field == "proc": + if field == "proc" and getter is not None: + tests.append(lambda c, m, x=matcher, g=getter: x.matches(*g(c, m))) + elif field == "proc": # The process kind judges (pid, name) together, exactly as the target # field does - so `proc:1234` and `proc:chrome` both work here for the # same reason they both work there. diff --git a/lang/en.json b/lang/en.json index a903a1c..7fd6401 100644 --- a/lang/en.json +++ b/lang/en.json @@ -617,6 +617,17 @@ "tips.tools_exprtest_pid": "The process ID, to test that as well. Used only for the Target process field.", "tips.tools_exprtest_use": "Puts this expression into the chosen field on the Control page, replacing its value. A running session picks it up after \"Apply changes\".", "tips.tools_exprtest_value": "The value to test: one IP address, one port or a process name.", + "tips.tools_sockets_col_local_ip": "The address on this computer. 0.0.0.0 or :: means every address.", + "tips.tools_sockets_col_local_port": "The port on this computer.", + "tips.tools_sockets_col_pid": "The id of the program that owns the socket. 0 means no program owns it any more (TIME_WAIT).", + "tips.tools_sockets_col_proc": "The program that owns the socket. Right-click a row to target it.", + "tips.tools_sockets_col_proto": "TCP or UDP.", + "tips.tools_sockets_col_remote_ip": "The address at the other end. Empty for a program that is listening, and for UDP. Right-click a row to limit the impairments to it.", + "tips.tools_sockets_col_remote_port": "The port at the other end.", + "tips.tools_sockets_col_state": "How far a TCP connection has got. Click ? for what each state means.", + "tips.tools_sockets_help": "What the columns and states mean, and how to search", + "tips.tools_sockets_refresh": "Reads every socket again. The status line says when the rows below were read.", + "tips.tools_sockets_search": "Narrows the table. Plain text searches every column, or write lport:8080 or state:listen to search one. Click ? for the full list.", "tips.up_limit": "Max throughput of OUTGOING traffic (upload) in KB/s. 0 = unlimited.", "tools.common.done": "Done at {time}, in {ms} ms.", "tools.common.failed": "This did not work: {error}", @@ -679,6 +690,20 @@ "tools.exprtest.use": "Use in the Control field", "tools.exprtest.value": "Test value:", "tools.exprtest.value_process": "Process name:", + "tools.sockets.col.local_ip": "local IP", + "tools.sockets.col.state": "state", + "tools.sockets.empty": "This computer has no sockets to show.", + "tools.sockets.empty_failed": "The socket table could not be read. The line above says why. Press Refresh to try again.", + "tools.sockets.empty_match": "No socket matches what you are looking for. The search works, nothing here fits it.", + "tools.sockets.error_denied": "the system would not list its sockets. Start the program as administrator, then press Refresh.", + "tools.sockets.error_missing": "there is no socket table to read on this system, because the psutil package is not installed. Install it with pip install psutil, then start the program again.", + "tools.sockets.help_body": "Every TCP and UDP socket on this computer: what is listening, what is connected, and which program owns it. No session is needed, so you can check which port your application uses before you start impairing it.\n\nThe table is read when you open this tab and when you press Refresh. The status line says when.\n\nColumns:\n local IP and l.port - the address and port on this computer. 0.0.0.0 or :: means every address.\n remote IP and r.port - the other end. Empty for a program that is listening, and for UDP.\n state - how far a TCP connection has got:\n LISTEN - waiting for connections.\n ESTABLISHED - connected.\n TIME_WAIT - closed a moment ago. No program owns it any more, so its PID is 0.\n CLOSE_WAIT - the other end has closed, but the program has not closed its side yet.\n SYN_SENT - connecting, no answer yet.\n PID and process - the program that owns the socket.\n\nSearch:\n Plain text searches every column.\n proc:chrome, pid:1234 - by program.\n lport:8080, lip:127.0.0.1 - by this computer's port or address.\n port:443, ip:10.0.0.0/8 - by the other end's port or address.\n state:listen, proto:udp - by state or protocol.\n Values are written as in the Control fields: 80,443 or 8000-8100, ! to leave something out, * for any text.\n\nRight-click a row to target its program, leave it alone, or limit to or block its remote address. That only fills in the Control fields: nothing changes until you press START or Apply changes.", + "tools.sockets.help_title": "Sockets", + "tools.sockets.note_failed": "Some tables could not be read, so their sockets are missing: {tables}.", + "tools.sockets.note_no_pid": "Sockets without a program: {count}. They belong to other accounts, and the system shows their owner only to an administrator.", + "tools.sockets.note_stale": "The rows below were read at {time}.", + "tools.sockets.refresh": "Refresh", + "tools.sockets.tab": "Sockets", "tools.unavailable": "This tool could not be opened. Restart the program to try again. The other tools keep working.", "warn.global_impairment": "This run has no target and no time limit, so it affects every connection on this machine. Set a target or a time limit to narrow it.", "warn.not_admin": "Not running as administrator - the WinDivert driver will not load and START will fail. Restart the app as administrator.", diff --git a/lang/pl.json b/lang/pl.json index f70e2b7..338383f 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -617,6 +617,17 @@ "tips.tools_exprtest_pid": "Identyfikator procesu, jeśli chcesz sprawdzić także go. Tylko dla pola Proces docelowy.", "tips.tools_exprtest_use": "Wpisuje to wyrażenie do wybranego pola na stronie Sterowanie w miejsce jego wartości. Działająca sesja przejmie je po „Zastosuj zmiany”.", "tips.tools_exprtest_value": "Wartość do sprawdzenia: jeden adres IP, jeden port albo nazwa procesu.", + "tips.tools_sockets_col_local_ip": "Adres na tym komputerze. 0.0.0.0 albo :: to każdy adres.", + "tips.tools_sockets_col_local_port": "Port na tym komputerze.", + "tips.tools_sockets_col_pid": "Identyfikator programu, do którego należy gniazdo. 0 znaczy, że nie należy już do żadnego programu (TIME_WAIT).", + "tips.tools_sockets_col_proc": "Program, do którego należy gniazdo. Kliknij wiersz prawym przyciskiem, żeby w niego celować.", + "tips.tools_sockets_col_proto": "TCP albo UDP.", + "tips.tools_sockets_col_remote_ip": "Adres po drugiej stronie. Pusty dla programu, który nasłuchuje, i dla UDP. Kliknij wiersz prawym przyciskiem, aby ograniczyć do niego zakłócenia.", + "tips.tools_sockets_col_remote_port": "Port po drugiej stronie.", + "tips.tools_sockets_col_state": "Jak daleko zaszło połączenie TCP. Co znaczy każdy stan - pod przyciskiem ?.", + "tips.tools_sockets_help": "Co znaczą kolumny i stany, i jak szukać", + "tips.tools_sockets_refresh": "Odczytuje wszystkie gniazda od nowa. Pasek stanu mówi, kiedy odczytano wiersze poniżej.", + "tips.tools_sockets_search": "Zawęża tabelę. Zwykły tekst szuka we wszystkich kolumnach, a lport:8080 czy state:listen w jednej. Pełna lista pod przyciskiem ?.", "tips.up_limit": "Maks. przepustowość ruchu WYCHODZĄCEGO (wysyłanie) w KB/s. 0 = bez limitu.", "tools.common.done": "Gotowe o {time}, w {ms} ms.", "tools.common.failed": "Nie udało się: {error}", @@ -679,6 +690,20 @@ "tools.exprtest.use": "Użyj w polu na Sterowaniu", "tools.exprtest.value": "Wartość do sprawdzenia:", "tools.exprtest.value_process": "Nazwa procesu:", + "tools.sockets.col.local_ip": "lokalne IP", + "tools.sockets.col.state": "stan", + "tools.sockets.empty": "Ten komputer nie ma żadnych gniazd do pokazania.", + "tools.sockets.empty_failed": "Nie udało się odczytać tabeli gniazd. Linia powyżej mówi dlaczego. Naciśnij Odśwież, żeby spróbować jeszcze raz.", + "tools.sockets.empty_match": "Żadne gniazdo nie pasuje do tego, czego szukasz. Wyszukiwarka działa, po prostu nic tu nie pasuje.", + "tools.sockets.error_denied": "system nie pokazał listy gniazd. Uruchom program jako administrator, a potem naciśnij Odśwież.", + "tools.sockets.error_missing": "na tym systemie nie ma skąd odczytać tabeli gniazd, bo nie zainstalowano pakietu psutil. Zainstaluj go poleceniem pip install psutil, a potem uruchom program ponownie.", + "tools.sockets.help_body": "Każde gniazdo TCP i UDP na tym komputerze: co nasłuchuje, co jest połączone i do którego programu należy. Sesja nie jest potrzebna, więc możesz sprawdzić, na jakim porcie działa Twoja aplikacja, zanim zaczniesz ją psuć.\n\nTabela jest odczytywana, gdy otwierasz tę kartę i gdy naciśniesz Odśwież. Pasek stanu mówi, kiedy.\n\nKolumny:\n lokalne IP i lok.port - adres i port na tym komputerze. 0.0.0.0 albo :: to każdy adres.\n zdalne IP i zd.port - druga strona. Puste dla programu, który nasłuchuje, i dla UDP.\n stan - jak daleko zaszło połączenie TCP:\n LISTEN - czeka na połączenia.\n ESTABLISHED - połączone.\n TIME_WAIT - zamknięte przed chwilą. Nie należy już do żadnego programu, więc ma PID 0.\n CLOSE_WAIT - druga strona zamknęła, a program jeszcze nie zamknął swojej.\n SYN_SENT - łączy się, jeszcze bez odpowiedzi.\n PID i proces - program, do którego należy gniazdo.\n\nWyszukiwanie:\n Zwykły tekst szuka we wszystkich kolumnach.\n proc:chrome, pid:1234 - po programie.\n lport:8080, lip:127.0.0.1 - po porcie albo adresie tego komputera.\n port:443, ip:10.0.0.0/8 - po porcie albo adresie drugiej strony.\n state:listen, proto:udp - po stanie albo protokole.\n Wartości pisze się jak w polach Sterowania: 80,443 albo 8000-8100, ! pomija, * zastępuje dowolny tekst.\n\nKliknij wiersz prawym przyciskiem, żeby celować w jego program, nie psuć go albo ograniczyć się do jego adresu zdalnego lub go zablokować. To tylko wypełnia pola Sterowania: nic się nie zmieni, dopóki nie naciśniesz START albo Zastosuj zmiany.", + "tools.sockets.help_title": "Gniazda", + "tools.sockets.note_failed": "Części tabel nie udało się odczytać, więc brakuje ich gniazd: {tables}.", + "tools.sockets.note_no_pid": "Gniazda bez programu: {count}. Należą do innych kont, a system pokazuje ich właściciela tylko administratorowi.", + "tools.sockets.note_stale": "Wiersze poniżej odczytano o {time}.", + "tools.sockets.refresh": "Odśwież", + "tools.sockets.tab": "Gniazda", "tools.unavailable": "Nie udało się otworzyć tego narzędzia. Uruchom aplikację ponownie, żeby spróbować jeszcze raz. Pozostałe narzędzia działają dalej.", "warn.global_impairment": "Ten przebieg nie ma celu ani limitu czasu, więc dotyczy każdego połączenia na tym komputerze. Zawęzisz go, ustawiając cel albo limit czasu.", "warn.not_admin": "Uruchomiono bez uprawnień administratora - sterownik WinDivert się nie załaduje i START się nie powiedzie. Uruchom aplikację ponownie jako administrator.", diff --git a/lang/zh.json b/lang/zh.json index f0eff85..45eba35 100644 --- a/lang/zh.json +++ b/lang/zh.json @@ -617,6 +617,17 @@ "tips.tools_exprtest_pid": "进程 ID,如需一并测试可填写。仅用于“目标进程”字段。", "tips.tools_exprtest_use": "把该表达式填入“控制”页中所选的字段,替换原有内容。正在运行的会话需点击“应用更改”后才会使用它。", "tips.tools_exprtest_value": "要测试的值:一个 IP 地址、一个端口或一个进程名称。", + "tips.tools_sockets_col_local_ip": "本机上的地址。0.0.0.0 或 :: 表示所有地址。", + "tips.tools_sockets_col_local_port": "本机上的端口。", + "tips.tools_sockets_col_pid": "拥有该套接字的程序的 ID。0 表示它已不属于任何程序(TIME_WAIT)。", + "tips.tools_sockets_col_proc": "拥有该套接字的程序。右键单击某一行可将其设为目标。", + "tips.tools_sockets_col_proto": "TCP 或 UDP。", + "tips.tools_sockets_col_remote_ip": "另一端的地址。正在监听的程序和 UDP 为空。右键单击某一行可将干扰仅限于该地址。", + "tips.tools_sockets_col_remote_port": "另一端的端口。", + "tips.tools_sockets_col_state": "TCP 连接所处的阶段。点击“?”查看各状态的含义。", + "tips.tools_sockets_help": "各列和状态的含义以及搜索方法", + "tips.tools_sockets_refresh": "重新读取所有套接字。状态行会显示下面各行的读取时间。", + "tips.tools_sockets_search": "筛选表格。普通文本会搜索所有列,也可以输入 lport:8080 或 state:listen 只搜索某一列。点击“?”可查看完整语法。", "tips.up_limit": "出站流量(上传)的最大吞吐量,单位为 KB/s。0 = 不限制。", "tools.common.done": "已于 {time} 完成,用时 {ms} 毫秒。", "tools.common.failed": "未能完成:{error}", @@ -679,6 +690,20 @@ "tools.exprtest.use": "在“控制”页的字段中使用", "tools.exprtest.value": "测试值:", "tools.exprtest.value_process": "进程名称:", + "tools.sockets.col.local_ip": "本地 IP", + "tools.sockets.col.state": "状态", + "tools.sockets.empty": "本机没有可显示的套接字。", + "tools.sockets.empty_failed": "无法读取套接字表。上面一行说明了原因。按“刷新”重试。", + "tools.sockets.empty_match": "没有与搜索内容匹配的套接字。搜索功能正常,只是这里没有符合条件的项。", + "tools.sockets.error_denied": "系统拒绝列出其套接字。请以管理员身份启动本程序,然后按“刷新”。", + "tools.sockets.error_missing": "此系统上没有可读取的套接字表,因为未安装 psutil 包。请用 pip install psutil 安装,然后重新启动本程序。", + "tools.sockets.help_body": "本机上的每个 TCP 和 UDP 套接字:哪些在监听、哪些已连接,以及它们属于哪个程序。不需要正在运行的会话,因此可以在开始干扰之前先查看你的应用使用哪个端口。\n\n打开此选项卡时以及按下“刷新”时会读取此表。状态行会显示读取时间。\n\n列:\n 本地 IP 和本地端口 - 本机上的地址和端口。0.0.0.0 或 :: 表示所有地址。\n 远程 IP 和远程端口 - 另一端。正在监听的程序和 UDP 为空。\n 状态 - TCP 连接所处的阶段:\n LISTEN - 等待连接。\n ESTABLISHED - 已连接。\n TIME_WAIT - 刚刚关闭。它已不属于任何程序,因此 PID 为 0。\n CLOSE_WAIT - 对方已关闭,程序尚未关闭自己这一端。\n SYN_SENT - 正在连接,尚无应答。\n PID 和进程 - 拥有该套接字的程序。\n\n搜索:\n 普通文本会搜索所有列。\n proc:chrome、pid:1234 - 按程序。\n lport:8080、lip:127.0.0.1 - 按本机的端口或地址。\n port:443、ip:10.0.0.0/8 - 按另一端的端口或地址。\n state:listen、proto:udp - 按状态或协议。\n 值的写法与“控制”页的字段相同:80,443 或 8000-8100,! 表示排除,* 表示任意文本。\n\n右键单击某一行,可以将其程序设为目标、排除它,或仅限、阻断其远程地址。这只会填写“控制”页的字段:在按下“开始”或“应用更改”之前,不会有任何变化。", + "tools.sockets.help_title": "套接字", + "tools.sockets.note_failed": "部分表无法读取,因此缺少其中的套接字:{tables}。", + "tools.sockets.note_no_pid": "没有程序的套接字:{count}。它们属于其他账户,系统只向管理员显示其所有者。", + "tools.sockets.note_stale": "下面各行读取于 {time}。", + "tools.sockets.refresh": "刷新", + "tools.sockets.tab": "套接字", "tools.unavailable": "无法打开此工具。请重新启动本程序后再试。其他工具仍可正常使用。", "warn.global_impairment": "本次运行既未设置目标,也未设置时间限制,因此会影响本机上的所有连接。请设置目标或时间限制以收窄范围。", "warn.not_admin": "当前未以管理员身份运行,WinDivert 驱动无法加载,“开始”会失败。请以管理员身份重新启动本程序。", diff --git a/tests/test_conns_columns.py b/tests/test_conns_columns.py index dd1e54c..0083fd3 100644 --- a/tests/test_conns_columns.py +++ b/tests/test_conns_columns.py @@ -245,7 +245,28 @@ def test_the_table_is_reachable_and_readable_without_a_mouse(): # ...and it refuses when there is no row to act on, exactly as the mouse # route refuses on an empty table page.table.select_keys([]) - assert page._popup_from_keyboard() == "break" + assert page.table.row_menu_from_keyboard() == "break" + + # What is BOUND, not only which handler exists: every route is fired + # through the callback the widget holds, on a real row, and must post the + # menu. Calling the handlers by name proved nothing about the bindings. + values = ["-"] * len(page.table.columns) + values[0] = "chrome.exe" + page.table.sync([("r1", values)]) + + class Ev: + x_root = y_root = y = 10 + + tree = page.table.tree + for seq in ("<>", "", menu_key): + page.menu.posted = 0 + tree.row_at = page.table._slots[0] + page.table.select_keys(["r1"]) + for callback in tree.bindings[seq]: + callback(Ev()) + assert page.menu.posted == 1, "%s did not open the menu" % seq + # the middle button is not a context menu (Tk maps <> per platform) + assert "" not in tree_binds, sorted(tree_binds) ''') diff --git a/tests/test_gui_state.py b/tests/test_gui_state.py index d65d651..4908af5 100644 --- a/tests/test_gui_state.py +++ b/tests/test_gui_state.py @@ -206,7 +206,7 @@ def test_blocking_and_excluding_from_a_row_accumulate(): except this", which is the case the menu entry exists for. """ run_gui(""" - from beantester.gui.pages.conns import block_ip_address, leave_process_alone + from beantester.gui.field_actions import block_ip_address, leave_process_alone block_ip_address(app, "8.8.8.8") block_ip_address(app, "1.1.1.1") @@ -274,7 +274,7 @@ class Ev: y = 10 tree.row_at = None # empty table / clicked below the rows - assert page._popup(Ev()) == "break" + assert page.table.row_menu_at_pointer(Ev()) == "break" assert page.menu.posted == 0, "menu shown with nothing to act on" # a real row. The table is virtualised, so identify_row() gives back a @@ -291,7 +291,7 @@ def row(proc): page.table.sync([("r1", row("chrome.exe"))]) tree.row_at = page.table._slots[0] - page._popup(Ev()) + page.table.row_menu_at_pointer(Ev()) assert page.table.selected_keys() == ["r1"] assert page.menu.posted == 1 assert page.menu.entry_states[page.TARGET_INDEX]["state"] == "normal" @@ -299,13 +299,13 @@ def row(proc): # a row whose process could not be resolved cannot be targeted page.table.sync([("r2", row("?"))]) tree.row_at = page.table._slots[0] - page._popup(Ev()) + page.table.row_menu_at_pointer(Ev()) assert page.menu.entry_states[page.TARGET_INDEX]["state"] == "disabled" # clicking a slot BELOW the last row acts on nothing tree.row_at = page.table._slots[-1] page.menu.posted = 0 - assert page._popup(Ev()) == "break" + assert page.table.row_menu_at_pointer(Ev()) == "break" assert page.menu.posted == 0, "menu shown for an empty viewport slot" """) diff --git a/tests/test_hot_path.py b/tests/test_hot_path.py index 2d4d325..f1da44e 100644 --- a/tests/test_hot_path.py +++ b/tests/test_hot_path.py @@ -24,8 +24,8 @@ So this file watches the ROUTES instead of an object. ``portmap`` is the only module in the package that touches ``psutil`` or ``iphlpapi``, and it does so -through five entry points; wrapping all five catches any caller, including one -nobody has written yet. Threads are compared by IDENTITY against the engine's own +through a handful of entry points (``OS_FUNCTIONS`` and ``NATIVE_METHODS``); +wrapping every one catches any caller, including one nobody has written yet. Threads are compared by IDENTITY against the engine's own handles rather than by name substring, so it does not depend on how CPython happens to name a thread. @@ -98,7 +98,14 @@ # level functions called through module globals, so replacing the attribute is # enough - production picks up the replacement at call time. OS_FUNCTIONS = ("_psutil_port_pid_map", "_psutil_process_table", - "_psutil_created", "_psutil_process_info", "_native_process_info") + "_psutil_created", "_psutil_process_info", "_native_process_info", + # The Tools tab's whole socket table (2026-09-23): read on a click by a + # worker. Watched like the rest, so the day something on the packet + # path reaches for it, this file says so. + "_psutil_socket_rows") +# The two walks over iphlpapi's tables, methods of the native binding: the port map +# the capture side reads, and the full table of the Tools tab. +NATIVE_METHODS = ("_table", "socket_rows") @contextlib.contextmanager @@ -107,7 +114,7 @@ def os_calls_recorded(): calls = [] lock = threading.Lock() originals = {name: getattr(portmap, name) for name in OS_FUNCTIONS} - native_table = portmap._Native._table + native = {name: getattr(portmap._Native, name) for name in NATIVE_METHODS} def wrap(name, original): def spy(*a, **kw): @@ -116,20 +123,17 @@ def spy(*a, **kw): return original(*a, **kw) return spy - def native_spy(self, *a, **kw): - with lock: - calls.append(("_Native._table", threading.current_thread())) - return native_table(self, *a, **kw) - for name, original in originals.items(): setattr(portmap, name, wrap(name, original)) - portmap._Native._table = native_spy + for name, original in native.items(): + setattr(portmap._Native, name, wrap("_Native." + name, original)) try: yield calls finally: for name, original in originals.items(): setattr(portmap, name, original) - portmap._Native._table = native_table + for name, original in native.items(): + setattr(portmap._Native, name, original) def test_no_packet_thread_ever_reaches_the_operating_system(): diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index 608509e..c47adb5 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -1191,7 +1191,7 @@ }, { "label": "keyboard: the context menu goes back to mouse-only", - "file": "beantester/gui/pages/conns.py", + "file": "beantester/gui/widgets/sortable_tree.py", "old": " for sequence in (\"\", menu_key):", "new": " for sequence in ():", "test": "test_the_table_is_reachable_and_readable_without_a_mouse", @@ -2834,6 +2834,204 @@ "new": " parts = parts[:-1]\n", "test": "test_a_package_init_resolves_its_relative_imports_inside_itself", }, + { + # The walk the capture-side port map and the Tools tab share: a table that + # outgrew the first buffer is dropped instead of asked again. + "label": "portmap: the socket table stops growing its buffer", + "file": "beantester/portmap.py", + "old": " if rc != _ERROR_INSUFFICIENT_BUFFER:\n return None", + "new": " if True:\n return None", + "test": "test_the_walk_grows_its_buffer_and_remembers_the_size", + }, + { + # The capture side's map starts installing TIME_WAIT rows as PID 0's ports. + "label": "portmap: the port map keeps the socket no process owns", + "file": "beantester/portmap.py", + "old": " if port and pid:\n # LAST ROW WINS", + "new": " if port:\n # LAST ROW WINS", + "test": "test_the_socket_table_keeps_every_row_the_port_map_drops", + }, + { + # A listener shows the 1.2.3.4:99 its row happens to hold. + "label": "portmap: a listener shows the remote half it does not have", + "file": "beantester/portmap.py", + "old": " \"\" if idle else _ipv4(row.dwRemoteAddr),", + "new": " _ipv4(row.dwRemoteAddr),", + "test": "test_the_socket_table_keeps_every_row_the_port_map_drops", + }, + { + # Learn's "network byte order", which the measurement contradicts. + "label": "portmap: the IPv6 scope is byte-swapped the way the docs say", + "file": "beantester/portmap.py", + "old": " return f\"{text}%{int(scope)}\" if scope else text", + "new": (" return (f\"{text}%{int.from_bytes(int(scope).to_bytes(4, 'little'), 'big')}\"\n" + " if scope else text)"), + "test": "test_the_socket_table_keeps_every_row_the_port_map_drops", + }, + { + # The two paths stop speaking the same words: state:syn_received misses psutil's. + "label": "portmap: psutil's state names are shown as psutil spells them", + "file": "beantester/portmap.py", + "old": " state = _PSUTIL_STATES.get(conn.status, conn.status) if proto == \"TCP\" else \"\"", + "new": " state = conn.status if proto == \"TCP\" else \"\"", + "test": "test_psutil_rows_speak_the_same_words_as_the_native_ones", + }, + { + # One refusing table throws away the three that answered. + "label": "portmap: one broken table sends the whole socket table to psutil", + "file": "beantester/portmap.py", + "old": " if len(failed) < len(_ROW_CONVERTERS):", + "new": " if not failed:", + "test": "test_an_empty_table_is_an_answer_and_an_error_code_is_not", + }, + { + # "Nobody may read it" becomes an empty table: a claim about the machine. + "label": "portmap: a refused socket table reads as an empty one", + "file": "beantester/portmap.py", + "old": " raise SocketTableUnavailable(\n \"denied\",", + "new": " return []\n raise SocketTableUnavailable(\n \"denied\",", + "test": "test_a_table_nobody_may_read_is_said_to_be_one", + }, + { + # The panel checks each answer against the box; without it a search typed + # during a read is never run. + "label": "sockets: a search typed during a read is never answered", + "file": "beantester/gui/toolbox/sockets.py", + "old": (" if latest is not None and not self._answers_the_inputs(latest):\n" + " self._asked_again()"), + "new": " if False:\n self._asked_again()", + "test": "test_a_search_typed_during_a_read_is_answered_on_that_read", + }, + { + # The first tool is built with the window: a read there runs at every start. + "label": "sockets: the table is read when the window opens", + "file": "beantester/gui/toolbox/sockets.py", + "old": " self.poller = Poller(self.frame, self.job, self._on_outcome)", + "new": (" self.poller = Poller(self.frame, self.job, self._on_outcome)\n" + " self._read_if_never()"), + "test": "test_the_socket_table_reads_when_first_looked_at_and_not_at_start_up", + }, + { + # A read that failed is asked again on every tick, forever. + "label": "sockets: a failed first read is retried on every tick", + "file": "beantester/gui/toolbox/sockets.py", + "old": " if READ not in self.job.last and not self.job.busy():", + "new": " if not self.job.value and not self.job.busy():", + "test": "test_a_first_read_that_fails_is_not_an_empty_machine_and_is_not_retried_by_itself", + }, + { + # The PID shows under "process" and the name under "PID". + "label": "sockets: a cell sits under another column's header", + "file": "beantester/gui/toolbox/sockets.py", + "old": " return (s.proc, \"\" if s.pid is None else s.pid, s.proto,", + "new": " return (\"\" if s.pid is None else s.pid, s.proc, s.proto,", + "test": "test_the_socket_table_reads_when_first_looked_at_and_not_at_start_up", + }, + { + # fe80::5%12 goes into dst_ip / block_ip: an interface, not an address. + "label": "sockets: the IPv6 zone goes into the Control field", + "file": "beantester/gui/toolbox/sockets.py", + "old": " return s.remote_ip.split(\"%\")[0]", + "new": " return s.remote_ip", + "test": "test_the_row_menu_offers_what_the_row_can_do_and_fills_the_control_fields", + }, + { + # "Target this process" offered on a TIME_WAIT row, "Block" on a listener. + "label": "sockets: the row menu offers what the row cannot do", + "file": "beantester/gui/toolbox/sockets.py", + "old": "state=\"normal\" if allowed else \"disabled\")", + "new": "state=\"normal\")", + "test": "test_the_row_menu_offers_what_the_row_can_do_and_fills_the_control_fields", + }, + { + # Two identical sockets, one key: a click on one selects the other. + "label": "sockets: two identical sockets share one key", + "file": "beantester/nettools/sockets.py", + "old": " yield Socket(f\"{base}|{repeat}\",", + "new": " yield Socket(f\"{base}\",", + "test": "test_two_identical_sockets_are_two_rows_with_two_keys", + }, + { + # A TIME_WAIT row is named "[System Process]", the snapshot's PID 0. + "label": "sockets: a socket no process owns is named after PID 0", + "file": "beantester/nettools/sockets.py", + "old": " name = names.get(row.pid, \"\") if row.pid else \"\"", + "new": " name = names.get(row.pid, \"\")", + "test": "test_a_read_names_each_row_after_the_table_and_keeps_what_failed", + }, + { + # 127.0.0.1 before 93.184.216.34, and IPv6 among IPv4. + "label": "sockets: addresses sort as text", + "file": "beantester/nettools/sockets.py", + "old": " key = cache[text] = (address.version, int(address))", + "new": " key = cache[text] = (0, text)", + "test": "test_a_column_sorts_by_value_with_empty_cells_last_both_ways", + }, + { + # An empty cell is sorted as the smallest value instead of no value. + "label": "sockets: empty cells sort first", + "file": "beantester/nettools/sockets.py", + "old": " return [s for _k, s in present] + [s for k, s in keyed if k is None]", + "new": " return [s for k, s in keyed if k is None] + [s for _k, s in present]", + "test": "test_a_column_sorts_by_value_with_empty_cells_last_both_ways", + }, + { + # A search after a failed Refresh shows the old rows as if they were fresh. + "label": "sockets: a search after a failed read drops the rows' age", + "file": "beantester/gui/toolbox/sockets.py", + "old": "stale=self._read_failed()", + "new": "stale=False", + "test": "test_a_read_that_fails_says_why_and_keeps_the_rows_it_had", + }, + { + # The reason is lost on the way: portmap's exception reaches the window as is. + "label": "sockets: a refused table reaches the window without its reason", + "file": "beantester/nettools/sockets.py", + "old": " raise Unreadable(UNREADABLE_KEYS.get(exc.reason, \"\"), str(exc)) from exc", + "new": " raise", + "test": "test_a_table_nobody_may_read_becomes_a_reason_the_window_can_say", + }, + { + # The reason stays, the way out goes: "install it" with no command to type. + "label": "sockets: a missing psutil no longer says how to get it", + "file": "lang/en.json", + "old": "Install it with pip install psutil, then start the program again.", + "new": "Install it, then start the program again.", + "test": "test_a_missing_psutil_says_how_to_get_it_in_every_language", + }, + { + # A failure the tool can name is shown as an English exception anyway. + "label": "toolbox: a known failure is shown as program text", + "file": "beantester/gui/toolbox/base.py", + "old": " error = T(outcome.error_key) if outcome.error_key else outcome.error", + "new": " error = outcome.error", + "test": "test_a_socket_table_the_system_refuses_is_said_in_the_windows_language", + }, + { + # The middle button opens the row menu on Windows and X11 again. + "label": "tables: the row menu opens on a middle click", + "file": "beantester/gui/widgets/sortable_tree.py", + "old": " self.tree.bind(\"<>\", self.row_menu_at_pointer)", + "new": (" self.tree.bind(\"<>\", self.row_menu_at_pointer)\n" + " self.tree.bind(\"\", self.row_menu_at_pointer)"), + "test": "test_the_table_is_reachable_and_readable_without_a_mouse", + }, + { + # The right click binds nothing: the handler exists, the table never calls it. + "label": "tables: the right click opens no menu", + "file": "beantester/gui/widgets/sortable_tree.py", + "old": " self.tree.bind(\"<>\", self.row_menu_at_pointer)", + "new": " self.tree.bind(\"<>\", lambda _event: \"break\")", + "test": "test_the_table_is_reachable_and_readable_without_a_mouse", + }, + { + # proc: on another table stops reading the PID: `proc:1234` finds nothing. + "label": "search: another table's proc: judges the name alone", + "file": "beantester/views.py", + "old": " tests.append(lambda c, m, x=matcher, g=getter: x.matches(*g(c, m)))", + "new": " tests.append(lambda c, m, x=matcher, g=getter: x.matches(None, g(c, m)[1]))", + "test": "test_the_search_is_the_connection_tables_language_on_these_columns", + }, ] # The runner's own check: a patch that cannot compile must be reported as BROKEN, not diff --git a/tests/test_nettools_sockets.py b/tests/test_nettools_sockets.py new file mode 100644 index 0000000..f9752cc --- /dev/null +++ b/tests/test_nettools_sockets.py @@ -0,0 +1,151 @@ +"""The socket table's logic (``nettools/sockets.py``): naming, keys, search, order. + +No window and no system: ``portmap.socket_rows`` and ``portmap.process_names`` are +stood in for, so the rows are the same on every machine. What the system really +returns is ``tests/test_socket_rows.py``'s business. +""" +import json +import os + +import pytest + +from beantester import portmap +from beantester.i18n import T +from beantester.nettools import sockets as sk +from beantester.portmap import SocketRow +from fakes import LANG_DIR, LANGS, check + +ROWS = [ + SocketRow("TCP", 4, "0.0.0.0", 445, "", None, "LISTEN", 4), + SocketRow("TCP", 4, "10.0.0.2", 50000, "93.184.216.34", 443, "ESTABLISHED", 1234), + SocketRow("TCP", 4, "127.0.0.1", 13882, "127.0.0.1", 5000, "TIME_WAIT", 0), + SocketRow("TCP", 6, "::", 135, "", None, "LISTEN", 900), + SocketRow("UDP", 4, "0.0.0.0", 5353, "", None, "", 100), + SocketRow("UDP", 4, "0.0.0.0", 5353, "", None, "", 100), # the same, twice + SocketRow("UDP", 6, "fe80::1%12", 546, "", None, "", 300), + SocketRow("TCP", 4, "10.0.0.2", 8080, "", None, "LISTEN", None), # another account's +] +NAMES = {0: "[System Process]", 4: "System", 1234: "chrome.exe", 900: "svchost.exe", + 100: "mdns.exe", 300: "dhcp.exe"} + + +@pytest.fixture +def machine(monkeypatch): + read = [] + + def socket_rows(): + read.append("table") + return list(ROWS), ["udp/v6"] + + def process_names(): + read.append("names") + return dict(NAMES) + + monkeypatch.setattr(portmap, "socket_rows", socket_rows) + monkeypatch.setattr(portmap, "process_names", process_names) + return read + + +def test_a_read_names_each_row_after_the_table_and_keeps_what_failed(machine): + snap = sk.read() + check("the table first, the names after it", machine == ["table", "names"], f"({machine})") + check("every row", len(snap.sockets) == len(ROWS), f"({len(snap.sockets)})") + check("what failed is carried", snap.failed == ("udp/v6",), f"({snap.failed})") + by_pid = {s.pid: s.proc for s in snap.sockets} + check("rows are named by their PID", by_pid[1234] == "chrome.exe" and by_pid[4] == "System") + check("PID 0 is nobody's, not the snapshot's '[System Process]'", by_pid[0] == "", + f"({by_pid[0]!r})") + check("an owner the system would not say has no name", by_pid[None] == "") + check("and is counted, so the panel can say why", sk.without_owner(snap) == 1) + + +@pytest.mark.parametrize("reason", sorted(sk.UNREADABLE_KEYS)) +def test_a_table_nobody_may_read_becomes_a_reason_the_window_can_say(monkeypatch, reason): + def refused(): + raise portmap.SocketTableUnavailable(reason, "the program's own words") + + monkeypatch.setattr(portmap, "socket_rows", refused) + with pytest.raises(sk.Unreadable) as caught: + sk.read() + key = caught.value.user_key + check("a key the window can say", key == sk.UNREADABLE_KEYS[reason], f"({key})") + check("and the language files know it", T(key) != key, f"({key})") + check("the program's words kept for the crash log", + str(caught.value) == "the program's own words", f"({caught.value})") + + +def test_a_missing_psutil_says_how_to_get_it_in_every_language(): + """Only a run from source can lack psutil (the exe carries it), and whoever runs + it needs the command, not just the reason. The command reads the same in every + language, so every language file is held to it.""" + key = sk.UNREADABLE_KEYS["missing"] + for code in LANGS: + with open(os.path.join(LANG_DIR, f"{code}.json"), encoding="utf-8") as f: + text = json.load(f).get(key, "") + check(f"lang/{code}.json names the command", "pip install psutil" in text, + f"({text})") + + +def test_two_identical_sockets_are_two_rows_with_two_keys(machine): + """One process can hold two UDP sockets on the same address and port. The table + finds rows by key through a dict, so the same key would send a click on one to + the other.""" + keys = [s.key for s in sk.read().sockets] + check("no key twice", len(keys) == len(set(keys)), f"({keys})") + twins = [s for s in sk.read().sockets if s.local_port == 5353] + check("the twins are both there", len(twins) == 2 and twins[0].key != twins[1].key) + check("and a read gives the same keys again", keys == [s.key for s in sk.read().sockets]) + + +@pytest.mark.parametrize("query, expected", [ + ("", {445, 50000, 13882, 135, 5353, 546, 8080}), + ("chrome", {50000}), # plain text: every column + ("time_wait", {13882}), + ("proc:svchost", {135}), + ("proc:1234", {50000}), # the process kind reads a PID too + ("pid:>1000", {50000}), + ("proto:udp", {5353, 546}), + ("state:listen", {445, 135, 8080}), + ("state:!listen", {50000, 13882, 5353, 546}), + ("ip:93.184.216.0/24", {50000}), # ip: and port: are the OTHER end + ("port:443", {50000}), + ("lip:127.0.0.1", {13882}), # lip: and lport: are this computer + ("lip:fe80::/10", {546}), # a zone does not stop a match + ("lport:5000-9000", {5353, 8080}), + ("lport:445 proto:tcp", {445}), # terms are ANDed + ("lip:10.0.", set()), # half-typed: nothing yet, no error + ("nosuch:thing", set()), # an unknown qualifier is plain text +]) +def test_the_search_is_the_connection_tables_language_on_these_columns(machine, query, expected): + kept = {s.local_port for s in sk.view(sk.read(), query)} + check(f"{query!r}", kept == expected, f"({sorted(kept)})") + + +def test_a_column_sorts_by_value_with_empty_cells_last_both_ways(machine): + snap = sk.read() + ports = [s.local_port for s in sk.view(snap, "", "local_port")] + check("ports by number", ports == sorted(ports), f"({ports})") + remote = [s.remote_ip for s in sk.view(snap, "", "remote_ip")] + check("addresses by number, not by text: 93.x before 127.x", + remote[:2] == ["93.184.216.34", "127.0.0.1"], f"({remote})") + check("and the rows with no remote end last", set(remote[2:]) == {""}, f"({remote})") + for reverse in (False, True): + pids = [s.pid for s in sk.view(snap, "", "pid", reverse)] + check(f"an unknown PID last (reverse={reverse})", pids[-1] is None, f"({pids})") + states = [s.state for s in sk.view(snap, "", "state", reverse)] + check(f"UDP's empty state last (reverse={reverse})", states[-1] == "", f"({states})") + local = [s.local_ip for s in sk.view(snap, "", "local_ip")] + check("IPv4 before IPv6", local.index("::") > local.index("127.0.0.1"), f"({local})") + check("an unknown column falls back to the default", + [s.local_port for s in sk.view(snap, "", "no-such-column")] == sorted(ports)) + + +def test_the_workers_job_reads_only_when_it_has_no_snapshot(machine): + first = sk.table(None, "state:listen", "local_port", False) + check("a read, then the view", machine == ["table", "names"] + and [s.local_port for s in first.rows] == [135, 445, 8080], f"({first.rows})") + again = sk.table(first.snapshot, "proto:udp", "local_port", True) + check("a view of the same read asks nothing", machine == ["table", "names"]) + check("and says what it answers", (again.query, again.sort) == ("proto:udp", ("local_port", True)) + and [s.local_port for s in again.rows] == [5353, 5353, 546], f"({again.rows})") + check("the newest view is the newest", again.made_at >= first.made_at) diff --git a/tests/test_socket_rows.py b/tests/test_socket_rows.py new file mode 100644 index 0000000..7ecf562 --- /dev/null +++ b/tests/test_socket_rows.py @@ -0,0 +1,298 @@ +"""The whole socket table for the Tools tab (``portmap.socket_rows``), and the +buffer walk it shares with the capture-side port map. + +The walk (``_Native._fetch``) had no test of its own before it was shared: every +test of the port map fakes ``_table`` whole. Here the fake is one level lower - an +``iphlpapi`` that writes a real table into the caller's buffer with the same ctypes +structures - so growing the buffer, refusing an error code and casting the rows run +on every platform. ``ctypes.wintypes`` imports on Linux too (its DWORD is 8 bytes +there); the fake writes with those same types, so what it writes is what the walk +reads. That the structures match Windows' own layout is what the live test at the +bottom proves where it can. +""" +import ctypes +import sys +from ctypes import wintypes +from typing import NamedTuple + +import pytest + +from beantester import portmap +from beantester.portmap import _AF_INET, _AF_INET6, SocketRow +from fakes import check + +TCP4, TCP6 = ("tcp", _AF_INET), ("tcp", _AF_INET6) +UDP4, UDP6 = ("udp", _AF_INET), ("udp", _AF_INET6) + + +def _v4(text): + """An address as the table stores it: a DWORD over the in_addr bytes.""" + return int.from_bytes(bytes(int(part) for part in text.split(".")), "little") + + +def _v6(text): + import ipaddress + return (ctypes.c_ubyte * 16)(*ipaddress.IPv6Address(text).packed) + + +def _port(number): + """Network order in the low 16 bits - and junk above, which Learn warns of.""" + return portmap._swap16(number) | 0xABCD0000 + + +class FakeIphlpapi: + """Writes each table into the caller's buffer the way iphlpapi does.""" + + def __init__(self, tables=None, refuse=None, grow_forever=False): + self.native = None # set by _native(): the row types live there + self.tables = tables or {} # (proto, family) -> [field dicts] + self.refuse = refuse or {} # (proto, family) -> an error code + self.grow_forever = grow_forever + self.calls = [] # ((proto, family), buffer size offered) + + def _answer(self, key, buffer, size_ref): + size = size_ref._obj # byref() keeps what it points at here + self.calls.append((key, size.value)) + if key in self.refuse: + return self.refuse[key] + if self.grow_forever: + size.value += 4096 # the table outgrew every buffer offered + return portmap._ERROR_INSUFFICIENT_BUFFER + row_type = self.native.rows[key] + rows = [row_type(**fields) for fields in self.tables.get(key, [])] + payload = bytes(wintypes.DWORD(len(rows))) + b"".join(bytes(r) for r in rows) + if size.value < len(payload): + size.value = len(payload) + return portmap._ERROR_INSUFFICIENT_BUFFER + ctypes.memmove(buffer, payload, len(payload)) + return 0 + + def GetExtendedTcpTable(self, buffer, size_ref, _order, family, _table_class, _reserved): + return self._answer(("tcp", family), buffer, size_ref) + + def GetExtendedUdpTable(self, buffer, size_ref, _order, family, _table_class, _reserved): + return self._answer(("udp", family), buffer, size_ref) + + +def _native(api): + native = portmap._Native(iphlpapi=api) + api.native = native + return native + + +def _machine(): + """One of everything the table can hold, and everything the port map drops.""" + return { + TCP4: [ + # a listener whose remote half holds junk: "no meaning" per Learn + dict(dwState=2, dwLocalAddr=_v4("0.0.0.0"), dwLocalPort=_port(445), + dwRemoteAddr=_v4("1.2.3.4"), dwRemotePort=_port(99), dwOwningPid=4), + dict(dwState=5, dwLocalAddr=_v4("10.0.0.2"), dwLocalPort=_port(50000), + dwRemoteAddr=_v4("93.184.216.34"), dwRemotePort=_port(443), + dwOwningPid=1234), + # closing: no process owns it any more (measured: PID 0) + dict(dwState=11, dwLocalAddr=_v4("127.0.0.1"), dwLocalPort=_port(13882), + dwRemoteAddr=_v4("127.0.0.1"), dwRemotePort=_port(5000), dwOwningPid=0), + # a state Learn does not list + dict(dwState=13, dwLocalAddr=_v4("10.0.0.2"), dwLocalPort=_port(50001), + dwRemoteAddr=_v4("10.0.0.9"), dwRemotePort=_port(80), dwOwningPid=77), + ], + TCP6: [dict(ucLocalAddr=_v6("::"), dwLocalScopeId=0, dwLocalPort=_port(135), + ucRemoteAddr=_v6("::"), dwRemoteScopeId=0, dwRemotePort=0, + dwState=2, dwOwningPid=900)], + # one port, two processes (SO_REUSEADDR, mDNS): two rows, not one + UDP4: [dict(dwLocalAddr=_v4("0.0.0.0"), dwLocalPort=_port(5353), dwOwningPid=100), + dict(dwLocalAddr=_v4("0.0.0.0"), dwLocalPort=_port(5353), dwOwningPid=200)], + UDP6: [dict(ucLocalAddr=_v6("fe80::1"), dwLocalScopeId=12, dwLocalPort=_port(546), + dwOwningPid=300)], + } + + +def test_the_socket_table_keeps_every_row_the_port_map_drops(): + """Every socket, one row each - and the capture side's map is unchanged beside it. + + The two read the same buffer walk, so both are asserted on the same machine: + the full table keeps the TIME_WAIT row (PID 0) and both owners of 5353, while + the port map still skips PID 0 and still keeps the last owner of a shared port. + """ + native = _native(FakeIphlpapi(_machine())) + rows, failed = native.socket_rows() + check("no table failed", failed == [], f"({failed})") + check("every row, in table order", rows == [ + SocketRow("TCP", 4, "0.0.0.0", 445, "", None, "LISTEN", 4), + SocketRow("TCP", 4, "10.0.0.2", 50000, "93.184.216.34", 443, "ESTABLISHED", 1234), + SocketRow("TCP", 4, "127.0.0.1", 13882, "127.0.0.1", 5000, "TIME_WAIT", 0), + SocketRow("TCP", 4, "10.0.0.2", 50001, "10.0.0.9", 80, "13", 77), + SocketRow("TCP", 6, "::", 135, "", None, "LISTEN", 900), + SocketRow("UDP", 4, "0.0.0.0", 5353, "", None, "", 100), + SocketRow("UDP", 4, "0.0.0.0", 5353, "", None, "", 200), + # The scope is taken as it is. Learn calls it network byte order; MEASURED + # 2026-09-23 it is not - a socket bound to fe80::...%12 has 12 in its row. + SocketRow("UDP", 6, "fe80::1%12", 546, "", None, "", 300), + ], f"({rows})") + + owners = {} + flat = native.port_pid_map(owners) + check("the port map skips the socket no process owns, as it always did", + flat == {445: 4, 50000: 1234, 50001: 77, 135: 900, 5353: 200, 546: 300}, + f"({flat})") + check("...and still records the owner it could not keep", owners == {5353: {100, 200}}, + f"({owners})") + + +def test_the_walk_grows_its_buffer_and_remembers_the_size(): + """A table larger than the first buffer answers 122 with the size it needs; the + walk tries again with that, and the next walk starts from it.""" + many = [dict(dwLocalAddr=_v4("0.0.0.0"), dwLocalPort=_port(1024 + n), dwOwningPid=n + 1) + for n in range(800)] # 800 x 12 B > 8192 + api = FakeIphlpapi({UDP4: many}) + native = _native(api) + rows, failed = native.socket_rows() + offered = [size for key, size in api.calls if key == UDP4] + check("the table that did not fit was asked twice: small, then big enough", + len(offered) == 2 and offered[0] == 8192 and offered[1] > 8192, f"({offered})") + check("and every row arrived", len([r for r in rows if r.proto == "UDP"]) == 800 and not failed, + f"({len(rows)}, {failed})") + + api.calls.clear() + native.port_pid_map() + offered = [size for key, size in api.calls if key == UDP4] + check("the next walk starts from the size that worked", len(offered) == 1, f"({offered})") + + +def test_a_table_that_keeps_outgrowing_its_buffer_is_a_failed_table(): + api = FakeIphlpapi(grow_forever=True) + rows, failed = _native(api).socket_rows() + check("nothing is invented", rows == [], f"({rows})") + check("all four are named as failed", failed == ["tcp/v4", "tcp/v6", "udp/v4", "udp/v6"], + f"({failed})") + check("each was given six tries and no more", + len([key for key, _size in api.calls if key == TCP4]) == 6, f"({api.calls})") + + +def test_an_empty_table_is_an_answer_and_an_error_code_is_not(monkeypatch): + """0 rows is a table that answered. Any code but 0 and 122 is a table that did + not - and when some answer, the caller gets their rows and the names of the + others, not a fallback that throws the answers away.""" + tables = _machine() + tables[UDP4] = [] + api = FakeIphlpapi(tables, refuse={TCP6: 87}) + monkeypatch.setattr(portmap, "_make_native", lambda: _native(api)) + + def no_fallback(): + raise AssertionError("three tables answered; psutil must not be asked") + + monkeypatch.setattr(portmap, "_psutil_socket_rows", no_fallback) + rows, failed = portmap.socket_rows() + check("the refusing table is named", failed == ["tcp/v6"], f"({failed})") + check("the empty one is not", "udp/v4" not in failed) + check("the others' rows are all here", + {(r.proto, r.family) for r in rows} == {("TCP", 4), ("UDP", 6)}, f"({rows})") + + +def test_when_no_native_table_answers_psutil_is_asked(monkeypatch): + api = FakeIphlpapi(refuse={TCP4: 5, TCP6: 5, UDP4: 5, UDP6: 5}) + monkeypatch.setattr(portmap, "_make_native", lambda: _native(api)) + rows = [SocketRow("TCP", 4, "127.0.0.1", 80, "", None, "LISTEN", 1)] + monkeypatch.setattr(portmap, "_psutil_socket_rows", lambda: rows) + check("psutil's rows, with nothing marked missing", portmap.socket_rows() == (rows, [])) + + monkeypatch.setattr(portmap, "_make_native", lambda: None) # off Windows + check("and off Windows it is the only way", portmap.socket_rows() == (rows, [])) + + +# -- the psutil path ------------------------------------------------------------ # +class _Addr(NamedTuple): + ip: str + port: int + + +class _Conn(NamedTuple): + laddr: object + raddr: object + status: str + pid: object + + +class FakePsutil: + class AccessDenied(Exception): + pass + + def __init__(self, tcp=(), udp=(), deny=False): + self.tcp, self.udp, self.deny = list(tcp), list(udp), deny + + def net_connections(self, kind): + if self.deny: + raise self.AccessDenied("(pid=None)") + return self.tcp if kind == "tcp" else self.udp + + +def test_psutil_rows_speak_the_same_words_as_the_native_ones(monkeypatch): + """psutil spells four states its own way; one search must find both paths' rows.""" + fake = FakePsutil( + tcp=[_Conn(_Addr("10.0.0.2", 50000), _Addr("10.0.0.9", 443), "SYN_RECV", 10), + _Conn(_Addr("10.0.0.2", 50001), _Addr("10.0.0.9", 443), "FIN_WAIT1", 11), + _Conn(_Addr("10.0.0.2", 50002), _Addr("10.0.0.9", 443), "FIN_WAIT2", 12), + _Conn(_Addr("10.0.0.2", 50003), _Addr("10.0.0.9", 443), "CLOSE", 13), + _Conn(_Addr("::", 8080), (), "LISTEN", None), # another account's + _Conn(_Addr("10.0.0.2", 50004), _Addr("10.0.0.9", 443), "BOUND", 14)], + udp=[_Conn(_Addr("0.0.0.0", 5353), (), "NONE", 20)]) + monkeypatch.setitem(sys.modules, "psutil", fake) + rows = portmap._psutil_socket_rows() + check("the states in the native path's words, an unknown one as psutil says it", + [r.state for r in rows] == ["SYN_RECEIVED", "FIN_WAIT_1", "FIN_WAIT_2", "CLOSED", + "LISTEN", "BOUND", ""], f"({rows})") + listener = rows[4] + check("a listener has no remote half, and an unknown owner stays unknown", + listener == SocketRow("TCP", 6, "::", 8080, "", None, "LISTEN", None), f"({listener})") + check("a UDP socket has no state", rows[-1] == SocketRow("UDP", 4, "0.0.0.0", 5353, + "", None, "", 20), f"({rows[-1]})") + + +def test_a_table_nobody_may_read_is_said_to_be_one(monkeypatch): + """An empty list would read as "no sockets" - a claim about the machine.""" + monkeypatch.setitem(sys.modules, "psutil", FakePsutil(deny=True)) + with pytest.raises(portmap.SocketTableUnavailable) as denied: + portmap._psutil_socket_rows() + check("refused", denied.value.reason == "denied", f"({denied.value.reason})") + + monkeypatch.setitem(sys.modules, "psutil", None) # import fails + with pytest.raises(portmap.SocketTableUnavailable) as missing: + portmap._psutil_socket_rows() + check("nothing to ask", missing.value.reason == "missing", f"({missing.value.reason})") + + +def test_process_names_come_from_one_snapshot_and_touch_no_cache(monkeypatch): + monkeypatch.setattr(portmap, "_process_table", + lambda: {4: ("System", 0, None), 1234: ("chrome.exe", 1, None)}) + table = portmap.default_table() + before = dict(table._info) + check("pid -> name", portmap.process_names() == {4: "System", 1234: "chrome.exe"}) + check("the targeting cache was not written", table._info == before) + + +# -- the real machine ------------------------------------------------------------ # +def test_the_real_socket_table_reads_as_sockets(): + """Whatever this machine holds today, every row must be a sane socket. + + It proves the one thing the fake cannot: that the structures match what + iphlpapi really writes (on Windows) and what psutil really returns (elsewhere). + A layout off by one field would show here as ports over 65535 or states that + are not states. + """ + try: + rows, failed = portmap.socket_rows() + except portmap.SocketTableUnavailable as exc: + pytest.skip(f"this machine will not show its socket table ({exc.reason})") + words = set(portmap.TCP_STATES.values()) + for row in rows: + check("a protocol", row.proto in ("TCP", "UDP"), f"({row})") + check("a port", 0 <= row.local_port <= 65535, f"({row})") + check("a state that is a state", + (row.state in words or row.state.isdigit()) if row.proto == "TCP" + else row.state == "", f"({row})") + if row.state == "LISTEN": + check("a listener has no remote half", row.remote_ip == "" and + row.remote_port is None, f"({row})") + if sys.platform == "win32": + check("on Windows every table answers", failed == [], f"({failed})") diff --git a/tests/test_toolbox.py b/tests/test_toolbox.py index 7606f95..b96963e 100644 --- a/tests/test_toolbox.py +++ b/tests/test_toolbox.py @@ -145,12 +145,20 @@ def focus_search(self): """), allow_faults=("on purpose",)) -def test_ctrl_f_on_the_tools_tab_goes_to_the_connection_search(): - """No tool has a search box yet, so Ctrl+F keeps doing what it did from any - page without one: bring the connection table forward.""" +def test_ctrl_f_on_the_tools_tab_finds_the_box_of_the_tool_on_screen(): + """The socket table has a search box of its own, and Ctrl+F on it goes there. A + tool without one sends the shortcut on to the connection table - what Ctrl+F did + from this tab before any tool had a box.""" run_gui(""" from beantester.gui.pages import focus_search app.select_page("tools") + page = app.pages["tools"] + page.select("sockets") + focus_search(app) + assert app.current_page() is page, app.current_page() + assert root.focus_get() is page.panels["sockets"].entry, root.focus_get() + + page.select("exprtest") focus_search(app) assert app.current_page() is app.pages["connections"], app.current_page() """) @@ -545,3 +553,243 @@ def test_the_environment_report_is_copied_whole(): assert any(T("log.copied") in line and T("tools.diagnostics.report_logged") in line for line in app._log_lines), app._log_lines[-3:] """)) + + +# -- sockets ------------------------------------------------------------------------ # +# The machine is stood in for at the logic's `read`: the real one asks the system, +# whose sockets differ on every runner. `gate` holds a read back for the tests that +# need one still running. +SOCK = """ +import threading, time +from beantester.i18n import T +from beantester.nettools import sockets as sk +from beantester.portmap import SocketRow + +ROWS = [SocketRow("TCP", 4, "0.0.0.0", 8080, "", None, "LISTEN", 1234), + SocketRow("TCP", 6, "fe80::1%12", 50001, "fe80::5%12", 443, "ESTABLISHED", 1234), + SocketRow("TCP", 4, "127.0.0.1", 13882, "127.0.0.1", 5000, "TIME_WAIT", 0), + SocketRow("UDP", 4, "0.0.0.0", 5353, "", None, "", 100)] +NAMES = {1234: "chrome.exe", 100: "mdns.exe"} +gate = threading.Event() +gate.set() +reads, failed_tables = [], [] + +def fake_read(): + reads.append(1) + gate.wait(5) + return sk.Snapshot(tuple(sk._sockets(ROWS, NAMES)), tuple(failed_tables), time.time(), 3) + +sk.read = fake_read + +def open_sockets(): + app.select_page("tools") + page = app.pages["tools"] + page.select("sockets") + return page.panels["sockets"] + +def settle(panel): + deadline = time.monotonic() + 5 + while panel.pending(): + assert time.monotonic() < deadline, "the worker never answered" + time.sleep(0.01) + +def shown(panel): + return [s.local_port for s in panel.table.items] + +def search(panel, text): + panel.query.set(text) + panel._typed() + panel.debounce.now() + +def select_port(panel, port): + key = next(s.key for s in panel.table.items if s.local_port == port) + panel.table.select_keys([key]) +""" + + +def test_the_socket_table_reads_when_first_looked_at_and_not_at_start_up(): + """Every page is built when the window opens, and the Tools page builds its first + tool with it - so a read in the constructor would ask the system at every start + of the program, for a tab nobody may open.""" + run_gui(SOCK + textwrap.dedent(""" + assert "sockets" in app.pages["tools"].panels, "the first tool is built at start" + app._tick() + assert reads == [], "and reads nothing until it is on screen" + + panel = open_sockets() + settle(panel) + assert reads == [1], reads + assert shown(panel) == [5353, 8080, 13882, 50001], shown(panel) + assert panel.count.cget("text") == T("conns.shown_of", shown=4, total=4) + assert panel.status.label.cget("style") == "Muted.TLabel" + # each cell under its own header - strict: one value per column, no fewer + from beantester.gui.toolbox.sockets import COLUMNS, render + cells = dict(zip(COLUMNS, render(panel.table.items[1]), strict=True)) + assert cells == {"proc": "chrome.exe", "pid": 1234, "proto": "TCP", + "local_ip": "0.0.0.0", "local_port": 8080, "state": "LISTEN", + "remote_ip": "", "remote_port": ""}, cells + app._tick() + panel.refresh() + settle(panel) + assert reads == [1], "after that, only Refresh reads" + panel.read() + settle(panel) + assert reads == [1, 1], reads + """)) + + +def test_a_search_typed_during_a_read_is_answered_on_that_read(): + """The worker keeps only the last request that waits, so a search queued behind a + read would be computed on the table it was queued against. Nothing is queued: the + search waits in the box, and the answer that lands is checked against it.""" + run_gui(SOCK + textwrap.dedent(""" + gate.clear() + panel = open_sockets() + panel.pending() + assert "disabled" in panel.refresh_btn.state(), "no second read while one runs" + search(panel, "proto:udp") + gate.set() + settle(panel) + assert shown(panel) == [5353], shown(panel) + assert reads == [1], "answered on the read it waited for, not by reading again" + assert "disabled" not in panel.refresh_btn.state() + """)) + + +def test_the_query_and_the_order_outlive_a_rebuild_of_the_window(): + run_gui(SOCK + textwrap.dedent(""" + panel = open_sockets() + settle(panel) + panel.table._clicked("local_port") # ascending -> descending + settle(panel) + assert shown(panel) == [50001, 13882, 8080, 5353], shown(panel) + search(panel, "proto:tcp") + settle(panel) + app._build_ui() + again = open_sockets() + settle(again) + assert again is not panel + assert again.query.get() == "proto:tcp" + assert again.table.sort == {"col": "local_port", "reverse": True}, again.table.sort + assert shown(again) == [50001, 13882, 8080], shown(again) + assert reads == [1], "a rebuild shows what the window read, it does not read again" + """)) + + +def test_a_read_that_fails_says_why_and_keeps_the_rows_it_had(): + run_gui(SOCK + textwrap.dedent(""" + panel = open_sockets() + settle(panel) + def refused(): + raise OSError("the socket table refused on purpose") + sk.read = refused + panel.read() + settle(panel) + status = panel.status.label + assert "refused on purpose" in status.cget("text"), status.cget("text") + assert status.cget("style") == "Status.Bad.TLabel" + assert shown(panel) == [5353, 8080, 13882, 50001], "the last good rows stay" + at = time.strftime("%H:%M:%S", time.localtime(panel._latest().snapshot.read_at)) + assert T("tools.sockets.note_stale", time=at) in panel.note.cget("text") + # a search is a new VIEW of the same old rows: they must still say how old + search(panel, "proto:tcp") + settle(panel) + assert shown(panel) == [8080, 13882, 50001], shown(panel) + assert T("tools.sockets.note_stale", time=at) in panel.note.cget("text"), \\ + panel.note.cget("text") + """), allow_faults=("on purpose",)) + + +def test_a_socket_table_the_system_refuses_is_said_in_the_windows_language(): + """A failure the tool can name is not an English exception in a Polish window; + the program's own words still go to the crash log.""" + run_gui(SOCK + textwrap.dedent(""" + def refused(): + raise sk.Unreadable("tools.sockets.error_denied", "the system refused on purpose") + sk.read = refused + panel = open_sockets() + settle(panel) + text = panel.status.label.cget("text") + assert text == T("tools.common.failed", error=T("tools.sockets.error_denied")), text + assert "on purpose" not in text, text + """), allow_faults=("on purpose",)) + + +def test_a_first_read_that_fails_is_not_an_empty_machine_and_is_not_retried_by_itself(): + run_gui(SOCK + textwrap.dedent(""" + calls = [] + def refused(): + calls.append(1) + raise OSError("nothing to read on purpose") + sk.read = refused + panel = open_sockets() + settle(panel) + assert panel.table.items == [] + assert panel.table._empty_text == "tools.sockets.empty_failed", panel.table._empty_text + for _ in range(3): + app._tick() + panel.refresh() + settle(panel) + assert calls == [1], "a failed read waits for Refresh, not for the next tick" + """), allow_faults=("on purpose",)) + + +def test_the_table_says_why_it_is_empty_and_what_its_rows_are_missing(): + run_gui(SOCK + textwrap.dedent(""" + ROWS.append(SocketRow("TCP", 4, "10.0.0.2", 9000, "", None, "LISTEN", None)) + failed_tables.append("udp/v6") + panel = open_sockets() + settle(panel) + note = panel.note.cget("text") + assert T("tools.sockets.note_failed", tables="udp/v6") in note, note + assert T("tools.sockets.note_no_pid", count=1) in note, note + search(panel, "state:closing") + settle(panel) + assert panel.table._empty_text == "tools.sockets.empty_match" + + ROWS.clear() + failed_tables.clear() + panel.read() + settle(panel) + assert panel.table._empty_text == "tools.sockets.empty", panel.table._empty_text + """)) + + +def test_the_row_menu_offers_what_the_row_can_do_and_fills_the_control_fields(): + """A row with no process (TIME_WAIT) cannot be targeted, a listener has no remote + address to limit to or block. The address goes into the field without its IPv6 + zone - the Control fields take an address, not an interface.""" + run_gui(SOCK + textwrap.dedent(""" + panel = open_sockets() + settle(panel) + menu = panel.menu + for port, named, remote in ((13882, False, True), (8080, True, False), + (50001, True, True)): + select_port(panel, port) + panel._show_menu(0, 0) + for index in (2, 3): + want = "normal" if named else "disabled" + assert menu.entry_states[index]["state"] == want, (port, index) + for index in (4, 5): + want = "normal" if remote else "disabled" + assert menu.entry_states[index]["state"] == want, (port, index) + + select_port(panel, 50001) + panel._limit() + limited = (app.vars["dst_ip"].get(), app.vars["dst_port"].get()) + assert limited == ("fe80::5", "443"), limited + panel._block() + assert app.vars["block_ip"].get() == "fe80::5", app.vars["block_ip"].get() + panel._target() + assert app.vars["target"].get() == "chrome.exe", app.vars["target"].get() + panel._leave_alone() + assert app.vars["target"].get() == "chrome.exe,!chrome.exe", app.vars["target"].get() + + # the right click itself goes through the table's shared route + class Ev: + x_root = y_root = y = 10 + panel.table.tree.row_at = None + menu.posted = 0 + assert panel.table.row_menu_at_pointer(Ev()) == "break" + assert menu.posted == 0, "a menu with no row under the pointer" + """))