diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da75ca..b35e5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ## [Unreleased] +### Added + +- **"Internet only (no local network)" - a new checkbox under "Traffic to modify", the mirror of + LAN mode.** It drops traffic to and from local addresses and leaves the internet up, so you can + test an app whose intranet server, NAS or printer has gone away. On the command line: + `--internet-only`. Its counter is "Local network cut". +- **Two things to know before ticking it.** Loopback keeps working, so anything talking to itself + on your own machine is untouched. But your router is on the local network, so if your PC asks it + for DNS, name lookups stop with everything else. Both checkboxes can be on at once - that cuts + everything except loopback, and the log says so when you apply it. + +- **The search box on the Control page can be switched off.** Settings (the cog) has a new + "Show the search box on the Control page" switch under Display. It is on by default, and + turning it off takes the box away at once - Ctrl+F then goes to the search box in the + Connections tab, the way it does from any other page. + +### Fixed + +- The right-click copy menu on the Statistics page opened with a white background instead of the + dark one used everywhere else. It now looks like the menu in the Connections table, on both + Live and Session. +- **A reproduction command left out `--narrow-filter`.** A session started with "Capture only the + targeted traffic" produced a command that re-ran a WIDER capture, so the packet counts of the + re-run could not match the report they came from. + ## [0.5.0] - 2026-08-19 **The short version.** Two things, and the first is about not losing your files when the diff --git a/README.md b/README.md index b755b33..33308f1 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,21 @@ public (internet) addresses and passes the local network: 10.0.0.0/8, 172.16-31. loopback, link-local and CGNAT. It simulates "LAN works, internet is down" - a test of how the app behaves without internet access (e.g. no gateway/WAN, a captive portal). +**Internet only** - the mirror of it, an "Internet only (no local network)" checkbox in the same +section. It drops traffic to/from those same local addresses and leaves the internet working, so +you can test an app that talks to an intranet server, a NAS or a printer while the WAN is fine. +Two things it does not do, and both matter: + +- **Loopback keeps working.** 127.x and `::1` pass either way, so a local development server, a + database on your own machine and anything using loopback to talk to itself are untouched. That + is deliberate: your own machine talking to itself is not "the local network". +- **The router is on the local network.** If your PC asks the router for DNS, name lookups stop + working with it, and the internet then looks broken even though internet traffic is being let + through. Use a public resolver while testing this if that gets in the way. + +Both checkboxes can be on at once. That is allowed and it cuts everything except loopback - the +program says so in the log when you apply it, and each of the two counters reports its own half. + **Target process** - narrow the effect to chosen apps: process name (e.g. `chrome.exe`), PID, a comma-separated list, PID range, wildcard or regular expression - see [Filter syntax](#filter-syntax-process--ip--port). The rest of the machine's traffic stays @@ -554,7 +569,7 @@ tool is overloaded), Dropped at stop (were still queued when STOP was pressed), tool captured them but could not put them back on the wire - the connection went down, or the driver refused), Rate-limit drop (dropped by a full speed-limit buffer - counted separately from loss and from "Buffer overflow"), SYN dropped, MTU dropped, NAT expired, RST torn, LAN: internet cut -off, RST sent. +off, Local network cut, RST sent. **Copying the figures.** Right-click any value on "Live" or "Session" to copy that value, or the whole tab. Each panel also has a button - "Copy counters" under the grid, "Copy session details" @@ -583,7 +598,7 @@ Designed so that after a bug you can recreate exactly the same conditions: - **Start / Duration / Effective loss / Queue peak / Down-up peak** - a quick picture of the run. - **What "Effective loss" counts** - the share of the traffic you aimed at that **this tool** broke, across **every** impairment: the configured Loss plus rate-limit drops, blocking, LAN cut, - link outages, connection resets, SYN drops, MTU drops and NAT expiry. With a target set, only + local-network cut, link outages, connection resets, SYN drops, MTU drops and NAT expiry. With a target set, only the target's traffic counts, so other applications cannot dilute it. Packets the **tool** threw away are deliberately excluded - "Buffer overflow", "Dropped at stop" and "Send failed" are its own failures, not the link's, and they have their own counters. The report's `effective_loss_pct` is the same @@ -691,6 +706,7 @@ BeanNetworkTester.exe --simulate --duration 30 --format json > run.ndjson | `--flap-period` `--flap-down` | s / % | cyclic link outage: how often and for what fraction of the period | | `--rate-schedule` | - | changing throughput: `"time:download:upload,..."` in KB/s, looped | | `--lan-mode` | - | LAN mode: cut off the internet (public addresses), keep the local network | +| `--internet-only` | - | the mirror: cut off the local network (10.x, 172.16-31.x, 192.168.x, link-local, CGNAT), keep the internet. Loopback keeps working. Careful: DNS asked of your router is local traffic, so the internet can stop working with it | | `--narrow-filter` | - | push `--dst-ip`/`--dst-port` into the WinDivert filter so the driver never hands over traffic that could not be impaired (much faster at high packet rates). START-time only. While it is on, statistics and connections cover the narrowed traffic only | **Targeting** (all three accept the full [filter syntax](#filter-syntax-process--ip--port): lists, @@ -828,7 +844,7 @@ tooltip on that header. | `l.port` | Port on this machine - what links the connection to a process. Empty for ping/ICMP, which is also why those rows usually have no process name. | | `packets` | Packets seen on this connection since it appeared. | | `impaired?` | Whether the connection was **in impairment scope** this session - impaired, not merely watched. It stays `yes` after the connection closes, as a record. With no targeting set, everything is in scope. | -| `dropped` | Packets dropped on this connection by the active impairments (loss, link outage, LAN mode, resets, ...). | +| `dropped` | Packets dropped on this connection by the active impairments (loss, link outage, LAN mode, "Internet only", resets, ...). | | `down` | Data that actually **reached** the application - what it downloaded. Same quantity the session panel calls "Downloaded (MB)". | | `up` | Data that actually **left** this machine - what the application uploaded. | | `total` | Delivered download + delivered upload. | @@ -903,6 +919,7 @@ what `packets_seen` counted in the first place - so every row records it in `cap | `dropped_nat` | dropped because the NAT mapping had expired | | `dropped_rst` | traffic swallowed while a connection was held down after a reset | | `dropped_lan` | dropped by LAN mode (internet cut, local network alive) | +| `dropped_local_network` | dropped by "Internet only" (local network cut, internet and loopback alive) | | `dropped_block` | dropped by the blocking (firewall) fields | | `dropped_link_outage` | dropped during a flapping outage | | `dropped_rate_limit` | dropped by a full speed-limit buffer | @@ -971,7 +988,8 @@ pause. **Which names go in `settings`** - any setting the tool has, under the **same name as the config file** (that is, its command-line flag with the dashes turned into underscores): `loss`, `latency`, `jitter`, `down`, `up`, `buffer`, `spike_prob`, `flap_period`, `dst_ip`, `block_port`, `target`, -`rate_schedule`, `max_size`, `nat_timeout`, `rst_prob`, `lan_mode`, `seed` and the rest. Run +`rate_schedule`, `max_size`, `nat_timeout`, `rst_prob`, `lan_mode`, `internet_only`, `seed` and the +rest. Run `--print-config` to dump the full set of names with their current values. **Everything is validated when the file loads, and a mistake names itself.** An unknown setting, an diff --git a/README.pl.md b/README.pl.md index 815d168..0a9da8b 100644 --- a/README.pl.md +++ b/README.pl.md @@ -178,6 +178,13 @@ Pola liczbowe są sprawdzane **na żywo, razem z zakresem** (np. utrata 0-100%, **Tryb LAN** - pole wyboru „Tryb LAN (tylko sieć lokalna, bez internetu)”. Odrzuca ruch do/od adresów publicznych (internet), a przepuszcza sieć lokalną: 10.0.0.0/8, 172.16-31.x, 192.168.x, loopback, link-local i CGNAT. Symuluje sytuację „LAN działa, internetu brak” - test zachowania aplikacji bez dostępu do internetu (np. brak bramy/WAN, portal przechwytujący). +**Tylko internet** - lustro tamtego, pole wyboru „Tylko internet (bez sieci lokalnej)” w tej samej sekcji. Odrzuca ruch do/od tych samych adresów lokalnych, a zostawia działający internet - można więc przetestować aplikację gadającą z serwerem w intranecie, z NAS-em albo z drukarką, przy sprawnym WAN-ie. Dwie rzeczy, których ta opcja NIE robi, i obie są ważne: + +- **Loopback działa dalej.** 127.x i `::1` przechodzą w obu trybach, więc lokalny serwer deweloperski, baza na tej samej maszynie i wszystko, co rozmawia samo ze sobą przez loopback, zostaje nietknięte. To jest celowe: własna maszyna gadająca sama ze sobą nie jest „siecią lokalną”. +- **Router jest w sieci lokalnej.** Jeśli komputer pyta o DNS router, razem z siecią lokalną przestaje działać rozwiązywanie nazw, a wtedy internet wygląda na zepsuty, choć ruch do internetu jest przepuszczany. Gdy to przeszkadza, ustaw na czas testu publiczny resolver. + +Oba pola wyboru można włączyć naraz. To jest dozwolone i odcina wszystko poza loopbackiem - program mówi o tym w logu przy zastosowaniu, a każdy z dwóch liczników raportuje swoją połowę. + **Celuj w proces** - zawęź działanie do wybranych aplikacji: nazwa procesu (np. `chrome.exe`), PID, lista po przecinku, zakres PID, wildcard lub wyrażenie regularne - patrz [Składnia filtrów](#składnia-filtrów-proces--ip--port). Reszta ruchu na komputerze pozostaje @@ -448,7 +455,7 @@ przeciążeniu narzędzia), Porzuc. przy stopie (czekały w kolejce, gdy nacisni (narzędzie je przechwyciło, ale nie zdołało odesłać do sieci - padło połączenie albo sterownik odrzucił pakiet), Odrzuc. przez limit (porzucone przez pełny bufor limitu prędkości - liczone osobno od strat i od „Bufor przepełn.”), SYN odrzucone, MTU odrzucone, NAT wygasło, -RST zerwane, LAN: internet odcięty, RST wysłane. +RST zerwane, LAN: internet odcięty, Sieć lokalna odcięta, RST wysłane. **Kopiowanie liczb.** Prawy przycisk na dowolnej wartości w „Na żywo" albo „Sesja" kopiuje tę wartość albo całą zakładkę. Każdy panel ma też przycisk - „Kopiuj liczniki" pod siatką, „Kopiuj @@ -469,7 +476,7 @@ Zaprojektowane tak, by po wystąpieniu błędu odtworzyć dokładnie te same war - **Powtarzalny flapping** - wzorzec przerw łącza liczony jest względem startu sesji, więc przy tych samych ustawieniach powtarza się identycznie między uruchomieniami (a nie zależy od zegara systemowego). - **Co dokładnie odtwarza seed** - seed odtwarza **decyzje** silnika (które pakiety zostaną porzucone, uszkodzone, zduplikowane, o ile opóźnione), a nie **liczbę pakietów**. Ruch, który przechodzi przez łącze, zależy od tego, co w danej chwili robią aplikacje i system, więc dwa przebiegi z tym samym seedem dadzą te same *proporcje* (np. 15,8% strat w obu), ale nie identyczne liczniki co do sztuki. Do porównań w CI używaj wskaźników (%), nie surowych liczb pakietów. - **Start / Czas trwania / Efektywna utrata / Szczyt kolejki / Szczyt down-up** - szybki obraz przebiegu. -- **Co liczą „Efektywne straty”** - jaką część ruchu, w który celujesz, zepsuło **to narzędzie**, licząc **każde** zakłócenie: ustawioną Utratę plus porzucenia z limitu prędkości, blokadę, odcięcie internetu w trybie LAN, przerwy w łączu, zrywanie połączeń, odrzucone SYN-y, odrzucenia z MTU i wygasanie NAT. Gdy ustawisz cel, liczy się wyłącznie jego ruch, więc inne aplikacje nie rozwadniają tej liczby. Pakiety porzucone przez samo **narzędzie** są świadomie pominięte - „Bufor przepełn.”, „Porzuc. przy stopie” i „Nie odesłane” to jego własne awarie, nie zachowanie łącza, i mają osobne liczniki. `effective_loss_pct` w raporcie to ta sama liczba, obok `packets_in_scope`. +- **Co liczą „Efektywne straty”** - jaką część ruchu, w który celujesz, zepsuło **to narzędzie**, licząc **każde** zakłócenie: ustawioną Utratę plus porzucenia z limitu prędkości, blokadę, odcięcie internetu w trybie LAN, odcięcie sieci lokalnej, przerwy w łączu, zrywanie połączeń, odrzucone SYN-y, odrzucenia z MTU i wygasanie NAT. Gdy ustawisz cel, liczy się wyłącznie jego ruch, więc inne aplikacje nie rozwadniają tej liczby. Pakiety porzucone przez samo **narzędzie** są świadomie pominięte - „Bufor przepełn.”, „Porzuc. przy stopie” i „Nie odesłane” to jego własne awarie, nie zachowanie łącza, i mają osobne liczniki. `effective_loss_pct` w raporcie to ta sama liczba, obok `packets_in_scope`. - **Czekanie w kolejce sterownika (szczyt)** - najdłuższy czas, jaki pakiet **już** przeczekał wewnątrz WinDiverta, zanim narzędzie go dostało. To pomiar, nie oszacowanie: sterownik stempluje każdy pakiet czasem przechwycenia, a narzędzie próbkuje to 20 razy na sekundę. Na spokojnej maszynie to ułamek milisekundy (zmierzone tutaj: 0,05-0,16 ms). Gdy rośnie, narzędzie dokłada opóźnienie, którego nie widać w żadnym innym liczniku, bo powstaje w kolejce sterownika przed jego własną - a powyżej 50 ms mówi o tym w logu i na liście zdarzeń. Puste przy `--simulate`, bo tam nie ma sterownika. - **To miara tej maszyny, nie internetu.** Narzędzie widzi pakiety przechodzące przez stos sieciowy tego komputera, więc pakiet zgubiony w sieci - odpowiedź, która nie wróciła - nigdy tu nie dociera i nic go tu nie policzy. Czysty ping 30 pakietów, w którym zginie jedna odpowiedź, pokaże w wierszu połączenia **59** pakietów i **zero** porzuceń, i obie liczby są prawdziwe: wyszło 30 żądań, wróciło 29 odpowiedzi, a narzędzie nie zepsuło żadnego. Od straty end-to-end są liczniki samej aplikacji (albo „Lost” w wyniku `ping`). - **Zużycie danych** - Pobrano / Wysłano / Razem (MB) narastająco od startu oraz średnia przepustowość sesji. Od razu wiesz, ile danych aplikacja zużyła. (W raporcie jest też „próbowano MB” - ile aplikacja chciała przesłać przed odjęciem strat/limitów.) @@ -552,6 +559,7 @@ BeanNetworkTester.exe --simulate --duration 30 --format json > run.ndjson | `--flap-period` `--flap-down` | s / % | cykliczne zrywanie łącza: co ile i na jaki ułamek okresu | | `--rate-schedule` | - | zmienna przepustowość: `"czas:pobieranie:wysyłanie,..."` w KB/s, w pętli | | `--lan-mode` | - | tryb LAN: odetnij internet (adresy publiczne), zostaw sieć lokalną | +| `--internet-only` | - | lustro tamtego: odetnij sieć lokalną (10.x, 172.16-31.x, 192.168.x, link-local, CGNAT), zostaw internet. Loopback działa dalej. Uwaga: DNS pytany u routera to ruch lokalny, więc internet może przestać działać razem z siecią lokalną | | `--narrow-filter` | - | wepchnij `--dst-ip`/`--dst-port` do filtra WinDiverta, żeby sterownik w ogóle nie podawał ruchu, którego nie dałoby się popsuć (dużo szybciej przy dużej liczbie pakietów). Tylko przy STARCIE. Gdy działa, statystyki i połączenia obejmują wyłącznie zawężony ruch | **Celowanie** (wszystkie trzy przyjmują pełną [składnię filtrów](#składnia-filtrów-proces--ip--port): listy, zakresy, `!`, `>`, `<`, `>=`, `<=`, wildcardy, `re:`, a `--dst-ip` dodatkowo CIDR) @@ -688,7 +696,7 @@ w podpowiedzi nad tym nagłówkiem. | `lok.port` | Port na tej maszynie - to on wiąże połączenie z procesem. Pusty dla ping/ICMP, dlatego te wiersze zwykle nie mają nazwy procesu. | | `pakiety` | Pakiety zobaczone na tym połączeniu, odkąd się pojawiło. | | `psute?` | Czy połączenie było **w zasięgu psucia** w tej sesji - psute, a nie tylko obserwowane. Zostaje na `tak` po zamknięciu połączenia, jako zapis. Bez ustawionego celowania wszystko jest w zasięgu. | -| `odrzucone` | Pakiety odrzucone na tym połączeniu przez aktywne zakłócenia (strata, przerwa w łączu, tryb LAN, resety, ...). | +| `odrzucone` | Pakiety odrzucone na tym połączeniu przez aktywne zakłócenia (strata, przerwa w łączu, tryb LAN, „Tylko internet”, resety, ...). | | `pobrane` | Dane, które **naprawdę dotarły** do aplikacji - tyle pobrała. To ta sama wielkość, którą panel sesji nazywa „Pobrano (MB)". | | `wysłane` | Dane, które **naprawdę wyszły** z tej maszyny - tyle aplikacja wysłała. | | `razem` | Dostarczone pobieranie + dostarczone wysyłanie. | @@ -762,6 +770,7 @@ w ogóle policzył - więc każdy wiersz zapisuje to w kolumnie `capture_narrowe | `dropped_nat` | odrzucone, bo mapowanie NAT wygasło | | `dropped_rst` | ruch pochłonięty, gdy połączenie było trzymane po resecie | | `dropped_lan` | odrzucone przez tryb LAN (internet odcięty, sieć lokalna żyje) | +| `dropped_local_network` | odrzucone przez „Tylko internet” (sieć lokalna odcięta, internet i loopback żyją) | | `dropped_block` | odrzucone przez blokadę (firewall) | | `dropped_link_outage` | odrzucone w trakcie przerwy w łączu (flapping) | | `dropped_rate_limit` | odrzucone przez pełny bufor limitu prędkości | @@ -830,7 +839,8 @@ błąd, a nie pauza. **Jakie nazwy wchodzą do `settings`** - dowolne ustawienie, jakie ma narzędzie, pod **tą samą nazwą co w pliku konfiguracji** (czyli jej flaga wiersza poleceń z myślnikami zamienionymi na podkreślenia): `loss`, `latency`, `jitter`, `down`, `up`, `buffer`, `spike_prob`, `flap_period`, `dst_ip`, `block_port`, -`target`, `rate_schedule`, `max_size`, `nat_timeout`, `rst_prob`, `lan_mode`, `seed` i reszta. +`target`, `rate_schedule`, `max_size`, `nat_timeout`, `rst_prob`, `lan_mode`, `internet_only`, +`seed` i reszta. `--print-config` wypisuje pełny zestaw nazw wraz z bieżącymi wartościami. **Wszystko jest sprawdzane przy wczytaniu, a pomyłka sama się nazywa.** Nieznane ustawienie, @@ -1113,7 +1123,7 @@ BeanNetworkTester.spec przepis builda (onedir, konsola, asInvoker) ## Jak to działa (skrót) Rdzeń `BeanCore.decide()` to czysta funkcja decydująca o losie pakietu w kolejności: -celowanie → tryb LAN → blokada (firewall) → NAT → RST → flapping → MTU → SYN → utrata → uszkodzenie → +celowanie → tryb LAN / tylko internet → blokada (firewall) → NAT → RST → flapping → MTU → SYN → utrata → uszkodzenie → opóźnienie/jitter/skok → limit przepustowości (token bucket z ograniczonym buforem, ew. z harmonogramu) → duplikacja. Wątek przechwytujący czyta pakiety i wykonuje decyzję. Wątek re-injektujący wysyła je w wyznaczonym momencie. Wszystkie losowania idą przez jeden generator (opcjonalnie seedowany). diff --git a/beantester/__init__.py b/beantester/__init__.py index 239501f..67467b8 100644 --- a/beantester/__init__.py +++ b/beantester/__init__.py @@ -46,8 +46,8 @@ validate_settings) from .summary import settings_summary from .synthetic import SyntheticDivert -from .utils import (_num, bytes_to_mb, canonical_ip, clamp01, is_local_ip, - nice_ceiling, to_number) +from .utils import (_num, bytes_to_mb, canonical_ip, clamp01, is_lan_ip, + is_local_ip, nice_ceiling, to_number) from .validators import parse_number, parse_seed from .views import filter_sort_connections, sort_events @@ -74,7 +74,9 @@ "find_process_ports", "parse_target", "port_process_map", "compile_target", "make_targeting", "ProcessTargeting", "portmap", "clamp01", "to_number", "_num", "bytes_to_mb", "nice_ceiling", "is_local_ip", - "canonical_ip", + # Exported beside its sibling on purpose: one of the pair being public and + # the other not is a trap ("which one does the local network mean?"). + "is_lan_ip", "canonical_ip", "sort_events", "filter_sort_connections", "build_arg_parser", "config_from_args", "apply_config", "run_cli", "main", "CliError", "exitcodes", "clilog", "driver", "winenv", diff --git a/beantester/cli.py b/beantester/cli.py index 277a5b1..8e289e9 100644 --- a/beantester/cli.py +++ b/beantester/cli.py @@ -145,6 +145,13 @@ def build_arg_parser(): "(e.g. '80,443,8000-8100' or '!53')") p.add_argument("--lan-mode", action="store_true", help="LAN mode: cut the internet (public addresses), keep the local network") + # NOT --lan-cut or --lan-block: a second option starting with "lan-" makes + # the --lan abbreviation ambiguous and argparse then refuses it outright. + p.add_argument("--internet-only", action="store_true", + help="cut the local network (10.x, 192.168.x, 172.16-31.x, " + "link-local, CGNAT), keep the internet. Loopback keeps " + "working. Careful: DNS asked of your router is local " + "traffic, so the internet can stop working with it") p.add_argument("--narrow-filter", action="store_true", help="push --dst-ip/--dst-port into the WinDivert filter, so the " "driver never hands over traffic that could not be impaired " @@ -361,7 +368,9 @@ def _sample_record(elapsed, down, up, s): down_kbps=round(down, 1), up_kbps=round(up, 1), packets=s["seen"], drop_loss=s["drop_loss"], drop_syn=s["drop_syn"], drop_nat=s["drop_nat"], drop_rst=s["drop_rst"], rst_sent=s["rst_sent"], - drop_lan=s["drop_lan"], drop_block=s["drop_block"], + drop_lan=s["drop_lan"], + drop_internet_only=s["drop_internet_only"], + drop_block=s["drop_block"], corrupted=s["corrupted"], duplicated=s["duplicated"], drop_overflow=s["drop_overflow"], drop_rate=s["drop_rate"], @@ -372,7 +381,8 @@ def _sample_text(elapsed, down, up, s): return (f"[{elapsed:6.1f}s] down={down:7.1f} up={up:7.1f} KB/s | " f"pkts={s['seen']} loss={s['drop_loss']} syn={s['drop_syn']} " f"nat={s['drop_nat']} rst={s['drop_rst']}/{s['rst_sent']} " - f"lan={s['drop_lan']} block={s['drop_block']} corrupt={s['corrupted']} " + f"lan={s['drop_lan']} localnet={s['drop_internet_only']} " + f"block={s['drop_block']} corrupt={s['corrupted']} " f"rate={s['drop_rate']} queue={s['queue']}") diff --git a/beantester/core.py b/beantester/core.py index 262aa46..29c2c3d 100644 --- a/beantester/core.py +++ b/beantester/core.py @@ -2,7 +2,7 @@ ``BeanCore.decide()`` inspects a single packet's metadata and returns a ``Decision``. The pipeline order (numbered below) is part of the contract: -1) process targeting -> 2) destination targeting -> 2b) LAN mode +1) process targeting -> 2) destination targeting -> 2b) LAN mode / Internet only -> 2c) blocking (firewall) -> 3) NAT -> 4) RST -> 5) flapping -> 6) MTU -> 7) SYN -> 8) loss -> 9) corruption -> 10) latency/jitter/spike -> 11) bandwidth (per-direction token bucket) -> 12) duplication. @@ -13,7 +13,7 @@ from typing import List, NamedTuple, Optional from .matchers import KIND_INT, KIND_IP, PORT_BOUNDS, parse_matcher, port_expression -from .utils import clamp01, is_local_ip +from .utils import clamp01, is_lan_ip, is_local_ip class Decision(NamedTuple): @@ -237,6 +237,12 @@ def __init__(self): self.dst_ip_matcher = parse_matcher("", KIND_IP) self.dst_port_matcher = parse_matcher("", KIND_INT) self.lan_only = False # LAN mode: cuts internet traffic (public addresses) + # The mirror switch: cuts the local network and leaves the internet up. + # NOT the exact opposite of the line above - loopback survives both (see + # utils.is_lan_ip). Both may be on at once: they judge the same packet + # from opposite sides, so nothing but loopback gets through, and each + # counter says how much of its own half it cut. + self.internet_only = False # blocking (firewall): drop traffic to matching destinations. The two # expressions combine with OR, and an EMPTY expression does not take part - # so block_port='443' with no block_ip blocks 443 to ANY address rather than @@ -345,6 +351,37 @@ def set_lan(self, enabled): with self._lock: self.lan_only = bool(enabled) + def set_internet_only(self, enabled): + with self._lock: + self.internet_only = bool(enabled) + + def _address_class_cut(self, remote_ip): + """Which address-class switch cuts this packet, or ``None``. + + Step 2b of the pipeline, in one place because the two switches are one + decision: they judge the same remote end from opposite sides and can + never both fire on one packet, so with BOTH armed nothing crosses except + loopback and each counter still reports its own half. + + 🔴 LOOPBACK survives either switch. ``is_local_ip`` counts it as local + (so LAN mode passes it) and ``is_lan_ip`` carves it out (so "Internet + only" passes it too) - a machine talking to itself is not "the local + network", and cutting it would take down a local development server on + the very machine the tool is running on. Owner's decision, 2026-08-19. + + Called only when a switch is armed (see the caller), so an ordinary + session never pays for the call. + """ + if not remote_ip: + # No remote end to judge: ICMP without addresses, a malformed packet. + # Neither switch may guess - both leave it alone. + return None + if self.lan_only and not is_local_ip(remote_ip): + return "lan" + if self.internet_only and is_lan_ip(remote_ip): + return "internet_only" + return None + def set_block(self, active, ip=None, port=None): """Blocking (firewall). ``ip``/``port`` are filter expressions (see :mod:`beantester.matchers`), so lists, ranges, CIDR, wildcards, ``re:`` @@ -567,9 +604,25 @@ def decide(self, size, is_outbound, local_port, now, rng, if self.dst_port_matcher and not self.dst_port_matcher.matches(remote_port): return Decision(False, False, [now], scoped=False) - # 2b) LAN mode: cut the internet (public addresses), keep the local network - if self.lan_only and remote_ip and not is_local_ip(remote_ip): - return Decision(True, False, [], "lan") + # 2b) the two address-class switches: LAN mode (cut the internet) and + # "Internet only" (cut the local network). Both are asked through one + # gate so this function does not grow a branch per switch - it sits on + # the complexity ceiling, and the answer to that is to move code out, + # not to raise the number. + # + # The left half keeps the common case cheap: with neither switch + # armed this costs two attribute reads and nothing else, and the call + # only happens in a session that asked for one of them. + # + # One statement rather than the obvious nested pair, MEASURED: ruff's + # complexity metric counts BRANCH STATEMENTS and not the boolean + # operators inside them, so a second `if` here reads as 30 while this + # form reads as 29 - the ceiling, which is pinned to the measurement + # and may not be raised to make room. The "is there a remote end at + # all" test lives inside the helper for the same reason. + if (self.lan_only or self.internet_only) and ( + cut := self._address_class_cut(remote_ip)): + return Decision(True, False, [], cut) # 2c) blocking (firewall): drop matching destinations. OR of the two # expressions, each taking part only when non-empty (an empty matcher diff --git a/beantester/engine.py b/beantester/engine.py index efbc4cf..800abc2 100644 --- a/beantester/engine.py +++ b/beantester/engine.py @@ -111,14 +111,16 @@ # rebuilt for every dropped packet, and a session set to 100% loss drops as often # as it sees. It is also the SINGLE SOURCE for what counts as damage below. DROP_BY_REASON = {"syn": "drop_syn", "mtu": "drop_mtu", "nat": "drop_nat", - "rst": "drop_rst", "lan": "drop_lan", "block": "drop_block", + "rst": "drop_rst", "lan": "drop_lan", + "internet_only": "drop_internet_only", "block": "drop_block", "flap": "drop_flap", "rate": "drop_rate"} # Damage the simulated link inflicted: every reason decide() can name, plus the # unnamed default (the configured Loss). Derived from the map above so that a new # impairment cannot quietly fall outside the figure - which is exactly how # "Effective loss" came to read 0.0% through a session losing 90% to a speed -# limit. Guarded by test_engine.py::test_every_drop_counter_is_classified. +# limit. Guarded by +# test_engine.py::test_every_drop_counter_and_drop_reason_is_classified. IMPAIRMENT_DROP_KEYS = (*dict.fromkeys(DROP_BY_REASON.values()), "drop_loss") # Losses the TOOL caused, not the link: its delay queue filled up, the session @@ -491,6 +493,9 @@ def process_target_active(self): def set_lan(self, *a): self.core.set_lan(*a) + def set_internet_only(self, *a): + self.core.set_internet_only(*a) + def set_block(self, *a): self.core.set_block(*a) @@ -559,7 +564,8 @@ def reset_stats(self): self.st = dict(seen=0, scoped_seen=0, drop_loss=0, drop_overflow=0, corrupted=0, duplicated=0, drop_syn=0, drop_mtu=0, drop_nat=0, - drop_rst=0, drop_lan=0, drop_block=0, drop_flap=0, + drop_rst=0, drop_lan=0, drop_internet_only=0, + drop_block=0, drop_flap=0, drop_rate=0, drop_shutdown=0, drop_send=0, # rst_reset counts CONNECTIONS torn down; drop_rst counts # the packets each one then swallows for its cooldown, and diff --git a/beantester/fields.py b/beantester/fields.py index 545a33b..2225693 100644 --- a/beantester/fields.py +++ b/beantester/fields.py @@ -88,6 +88,17 @@ class Field(NamedTuple): # with the whole rest of the form at zero, cuts the machine's internet. Field("lan_mode", BOOL, "fields.lan_mode", "traffic", tip="tips.lan_mode", span=True, cli="lan-mode", impairs=IMPAIRS_ALL), + # The mirror of the line above, and IMPAIRS_ALL for the same reason: on its + # own, with the whole rest of the form at zero, it cuts every local address + # this machine talks to. Loopback survives (utils.is_lan_ip). + # + # 🔴 The CLI flag may not begin with "lan-". MEASURED: a second --lan-* + # option makes the abbreviation --lan ambiguous, and argparse then REFUSES it + # with exit 2 - so naming it --lan-cut would silently break a documented + # shortcut of --lan-mode (allow_abbrev stays on: ADR 2026-08-02). + Field("internet_only", BOOL, "fields.internet_only", "traffic", + tip="tips.internet_only", span=True, cli="internet-only", + impairs=IMPAIRS_ALL), # -- target process ---------------------------------------------------- # Field("target", EXPR, TARGET_FIELD, "target_process", expr_kind=KIND_PROCESS, @@ -290,7 +301,8 @@ class Section(NamedTuple): # preset picker must be the first thing they see - not the last section after # a dozen panels of NAT/MTU/RST jargon. Section("profiles", "frames.profiles", (), columns=1, extra="profiles"), - Section("traffic", "frames.traffic", ("filter", "lan_mode"), columns=1), + Section("traffic", "frames.traffic", ("filter", "lan_mode", "internet_only"), + columns=1), # No "enable" checkbox: an empty target already means "all traffic", so the # checkbox was a switch that did nothing but take a click (same for the two # sections below). diff --git a/beantester/gui/csv_export.py b/beantester/gui/csv_export.py index c6e7f1d..bb5f87a 100644 --- a/beantester/gui/csv_export.py +++ b/beantester/gui/csv_export.py @@ -25,6 +25,7 @@ "drop_mtu": "dropped_mtu", "drop_nat": "dropped_nat", "drop_rst": "dropped_rst", "rst_reset": "connections_reset", "drop_lan": "dropped_lan", + "drop_internet_only": "dropped_local_network", "drop_block": "dropped_block", "drop_flap": "dropped_link_outage", "drop_rate": "dropped_rate_limit", "drop_shutdown": "dropped_at_stop", diff --git a/beantester/gui/pages/__init__.py b/beantester/gui/pages/__init__.py index 3ff5621..b473622 100644 --- a/beantester/gui/pages/__init__.py +++ b/beantester/gui/pages/__init__.py @@ -41,8 +41,12 @@ def focus_search(app): """ page = app.current_page() if page is not None and hasattr(page, "focus_search"): - page.focus_search() - return "break" + # A page can REFUSE: the Control page's box can be switched off in the + # Settings window, and a shortcut that focuses a widget nobody can see is + # worse than one that does nothing. Refusing sends the user to the table, + # which is what Ctrl+F did before that page had a box at all. + if page.focus_search() is not False: + return "break" app.select_page(SEARCH_FALLBACK) fallback = app.pages.get(SEARCH_FALLBACK) if fallback is not None: @@ -50,4 +54,35 @@ def focus_search(app): return "break" -__all__ = ["PAGES", "Page", "ControlPage", "StatsPage", "ConnsPage", "focus_search"] +def pref_changed(app, key): + """A GUI preference was written: let the pages that react to one react NOW. + + Preferences are stored and then simply READ - the chart asks for its history + length on the next tick, ``scoped_stat`` asks for the view scope on every + figure it prints. That is enough for anything the tick redraws anyway, and it + is NOT enough for a switch whose whole visible effect is a widget appearing or + disappearing: up to one tick (0.7 s) of a ticked box doing nothing reads as a + broken checkbox, not as a slow one. + + A broadcast rather than a name in the registry, and the reason is the shape of + the alternative: ``Pref`` would have to carry the name of an ``App`` method, + and ``gui/app.py`` sits ON the size ratchet with zero headroom + (``tests/test_code_shape.py``), so that method would have to live somewhere + else and be reached through App anyway. This is the same dispatcher shape as + ``focus_search`` above, in the same place, for the same reason: how a page is + ADDRESSED belongs to the page registry. + + A page that raises must not take the Settings window down with it - the user + would be left with a checkbox they cannot untick. + """ + from ... import crashlog + for page in app.pages.values(): + handler = getattr(page, "on_pref_changed", None) + if handler is None: + continue + with crashlog.quiet("gui.pages"): + handler(key) + + +__all__ = ["PAGES", "Page", "ControlPage", "StatsPage", "ConnsPage", + "focus_search", "pref_changed"] diff --git a/beantester/gui/pages/control.py b/beantester/gui/pages/control.py index 2e3564b..acba8ae 100644 --- a/beantester/gui/pages/control.py +++ b/beantester/gui/pages/control.py @@ -67,10 +67,16 @@ def __init__(self, app, parent): "profiles": self._build_profiles, }) app.form = self.form + # Applied only now, with the scroller and the form already built: putting + # the bar BACK needs `before=` to name the scroller, and hiding it runs a + # clear, which talks to the form. `_build_search_bar` packed it, so that + # is the state this starts from. + self._search_shown = True + self._sync_search_visibility() # A query typed before a language switch (or before the window was # widened into two columns) is still in the box after the rebuild, so the # marks have to come back with it. - if _LAST_QUERY[0]: + if self._search_shown and _LAST_QUERY[0]: self.frame.after_idle(self._apply) # -- search -------------------------------------------------------------- # @@ -95,11 +101,8 @@ def _build_search_bar(self): leftwards into empty space without moving anything. """ bar = ttk.Frame(self.frame) - # Low rather than high: sitting under the tab strip with a wide gap below - # it, the bar looked attached to the tabs. Tight to the content it belongs - # to, it reads as the page's own header row. - bar.pack(side="top", fill="x", padx=(scaled(12), scaled(14)), - pady=(scaled(12), scaled(3))) + self._bar = bar + self._pack_bar() self.query_var = tk.StringVar(value=_LAST_QUERY[0]) entry = ttk.Entry(bar, textvariable=self.query_var, width=24) entry.pack(side="right") @@ -127,10 +130,79 @@ def _build_search_bar(self): self.app.root.bind("", lambda e: _dispatch(self.app)) self.app.root.bind("", lambda e: _dispatch(self.app)) + def _pack_bar(self, **extra): + """Where the bar sits, in ONE place - it is packed twice. + + Once at build time and once when the preference brings it back, and two + copies of these numbers would drift the day somebody tunes one of them. + + The padding is low rather than high on purpose: sitting under the tab + strip with a wide gap below it, the bar looked attached to the tabs. Tight + to the content it belongs to, it reads as the page's own header row. + + 🔴 Bringing it back passes ``before=``. pack hands out space in CALL + order, so a bar re-packed after the scroller exists would land UNDER the + page body - and the fake tkinter cannot see that (it checks that + ``before=`` names a sibling and keeps children in creation order), so this + one is answered by a live render, not by the suite. + """ + self._bar.pack(side="top", fill="x", padx=(scaled(12), scaled(14)), + pady=(scaled(12), scaled(3)), **extra) + + def search_is_visible(self): + """Is the search bar on the page? (the preference, read live)""" + return bool(self.app.pref("show_control_search")) + + def on_pref_changed(self, key): + """A preference was written in the Settings window (see gui/pages).""" + if key == "show_control_search": + self._sync_search_visibility() + + def _sync_search_visibility(self): + """Bring the bar in or out, once per actual change.""" + want = self.search_is_visible() + if want == self._search_shown: + return + self._search_shown = want + if want: + self._pack_bar(before=self.scroll.vsb) + else: + self._hide_search() + + def _hide_search(self): + """Take the bar away, and leave nothing of the search behind. + + Clearing first is not tidiness. The marks are painted on the FORM, not on + the bar, so a query left standing would leave fields highlighted with no + box left to clear them from - and the sections the search had unfolded + would stay unfolded. A debounce still in flight would repaint both a + moment after the bar was gone. + """ + if self._job is not None: + with crashlog.quiet("gui.pages.control"): + self.frame.after_cancel(self._job) + self._job = None + self.query_var.set("") + self._apply() # unmarks, refolds, forgets the query + with crashlog.quiet("gui.pages.control"): + # Only if the caret is actually in there: typing into a widget that + # is no longer on screen is the one way this could swallow keystrokes. + if self.frame.focus_get() is self._entry: + self.frame.focus_set() + self._bar.pack_forget() + def focus_search(self): - """Put the caret in the box (the Ctrl+F path, see gui/app.py).""" + """Put the caret in the box (the Ctrl+F path, see gui/app.py). + + ``False`` means "not available here": with the box switched off the + dispatcher passes Ctrl+F on to the connection table, which is what the + shortcut did from this page before it had a box at all. + """ + if not self._search_shown: + return False self._entry.focus_set() self._entry.select_range(0, "end") + return True def _on_key(self, event): # Enter and Escape have their own bindings; letting them through here diff --git a/beantester/gui/pages/stats.py b/beantester/gui/pages/stats.py index 8d80a9c..8fbb511 100644 --- a/beantester/gui/pages/stats.py +++ b/beantester/gui/pages/stats.py @@ -22,7 +22,7 @@ from ..scaling import scaled from ..scrollable import ScrollableFrame from .. import scope -from ..theme import BG2, DOWN_C, EVENT_COLORS, UP_C +from ..theme import BG2, DOWN_C, EVENT_COLORS, UP_C, style_menu from ..tooltip import add_tooltip, retip from ..widgets import SortableTree from ... import crashlog @@ -75,6 +75,7 @@ ("drop_nat", "stats.nat_expired", "", "tips.stat_nat"), ("drop_rst", "stats.rst_reset", "", "tips.stat_rst"), ("drop_lan", "stats.lan_cut", "", "tips.stat_lan"), + ("drop_internet_only", "stats.local_cut", "", "tips.stat_local"), ("drop_block", "stats.block_cut", "", "tips.stat_block"), ("drop_flap", "stats.flap_cut", "", "tips.stat_flap"), ("rst_sent", "stats.rst_sent", "", "tips.stat_rst_sent"), @@ -275,7 +276,14 @@ def _build_session(self, parent): def _copy_menu(self): menu = getattr(self, "_menu", None) if menu is None: - menu = tk.Menu(self.frame, tearoff=0) + # style_menu, exactly like the connection table's menu: ttk styles do + # not reach a classic tk.Menu (on Windows it is a native Win32 popup), + # so a menu built without it comes up in the SYSTEM colours - a white + # box in the middle of a dark program. Nothing in the suite could see + # that: the fake tkinter records colours without rendering them, which + # is why this line now has a rule guarding it rather than a comment + # asking the next person to remember. + menu = style_menu(tk.Menu(self.frame, tearoff=0)) menu.add_command(label=T("menu.copy_value"), command=self._copy_one) menu.add_command(label=T("menu.copy_all"), command=self._copy_panel) self._menu = menu @@ -485,7 +493,8 @@ def refresh_counters(self): for key in ("seen", "queue", "drop_loss", "corrupted", "duplicated", "drop_overflow", "drop_shutdown", "drop_send", "drop_rate", "drop_syn", "drop_mtu", - "drop_nat", "drop_rst", "drop_lan", "drop_block", "drop_flap", "rst_sent"): + "drop_nat", "drop_rst", "drop_lan", "drop_internet_only", + "drop_block", "drop_flap", "rst_sent"): self.stat_labels[key].config(text=str(self.app.scoped_stat(snap, key))) def refresh_session(self): diff --git a/beantester/gui/panels/settings.py b/beantester/gui/panels/settings.py index 285ec75..282c91a 100644 --- a/beantester/gui/panels/settings.py +++ b/beantester/gui/panels/settings.py @@ -235,7 +235,7 @@ def _build_pref_row(self, card, pref): var = tk.BooleanVar(value=bool(app.pref(pref.key))) chk = ttk.Checkbutton( row, text=T(pref.label), variable=var, - command=lambda k=pref.key, v=var: app.set_pref(k, bool(v.get()))) + command=lambda k=pref.key, v=var: self._store(k, bool(v.get()))) chk.pack(side="left", anchor="w") add_tooltip(chk, pref.tip) self._pref_vars[pref.key] = var @@ -283,10 +283,25 @@ def _on_pref_number(self, pref): else: entry.config(style="TEntry") self._pref_messages.pop(pref.key, None) - self.app.set_pref( - pref.key, int(value) if float(value).is_integer() else value) + self._store(pref.key, + int(value) if float(value).is_integer() else value) self._show_pref_errors() + def _store(self, key, value): + """Persist a preference and tell the pages, in ONE place. + + Both kinds of row write through here so that "a preference was changed" + has a single meaning: the value is on disk (``App.set_pref`` persists + immediately - a preference must survive a hard crash) and anything that + has to look different NOW already does. Without the second half a switch + waits for the next tick, and the one that shows or hides a widget looks + broken while it waits. See ``gui/pages/__init__.py::pref_changed``. + """ + self.app.set_pref(key, value) + with crashlog.quiet("gui.panels.settings"): + from ..pages import pref_changed + pref_changed(self.app, key) + def _show_pref_errors(self): """List every live reason under its group, the way ControlForm does.""" for err, keys in self._pref_errors.values(): diff --git a/beantester/gui/prefs.py b/beantester/gui/prefs.py index 4bfa1ab..803d928 100644 --- a/beantester/gui/prefs.py +++ b/beantester/gui/prefs.py @@ -65,7 +65,15 @@ class Pref(NamedTuple): # `seen` AND `scoped_seen` regardless, so a pipeline never has to guess which # world a file came from. Pref("scope_view_to_target", BOOL, "prefs.scope_view", "tips.scope_view", - default=False, hint="prefs.scope_view_hint", section="scope"), + default=False, section="scope"), + # Default True: the box has always been there, and a preference is allowed to + # take something away only when the user asks. + # + # Worded POSITIVELY on purpose. "Hide the search box" plus an unticked box is + # a double negative to read, and it would make this the one switch in the + # window where a tick means less rather than more. + Pref("show_control_search", BOOL, "prefs.show_control_search", + "tips.show_control_search", default=True), # -- behaviour --------------------------------------------------------- # Pref("confirm_close", BOOL, "prefs.confirm_close", "tips.confirm_close", default=True), @@ -81,7 +89,7 @@ class Pref(NamedTuple): # Prefs that name a ``section`` are rendered there instead and must NOT appear # here - see ``SECTION_PREFS`` below. PREF_GROUPS = ( - ("prefs.group_view", ("chart_seconds", "log_lines")), + ("prefs.group_view", ("chart_seconds", "log_lines", "show_control_search")), ("prefs.group_behaviour", ("confirm_close", "restore_profile", "reset_layout")), ) diff --git a/beantester/repro.py b/beantester/repro.py index 8049e20..d720f58 100644 --- a/beantester/repro.py +++ b/beantester/repro.py @@ -43,6 +43,16 @@ def settings_to_cli(settings, seed=None, simulate=False): args += ["--block-port", block_port] if g("lan_mode"): args += ["--lan-mode"] + if g("internet_only"): + args += ["--internet-only"] + # START-only, and it changes what the session even SAW - a command without it + # re-runs a wider capture, so `packets` and every percentage derived from it + # describe a different run. It was missing until the guard below went looking + # (test_summary_repro_views.py::test_every_setting_with_a_flag_reaches_the_ + # reproduction_command); the repro REPORT has carried `narrowed` all along, + # which is why nobody noticed the command did not. + if g("narrow_filter"): + args += ["--narrow-filter"] filt = g("filter") if filt and filt != "both": args += ["--filter", str(filt)] @@ -92,6 +102,7 @@ def build_repro_report(engine, settings): syn_dropped=stats["drop_syn"], nat_expired=stats["drop_nat"], blocked=stats["drop_block"], + local_network_dropped=stats.get("drop_internet_only", 0), rate_dropped=stats["drop_rate"], peak_queue=stats.get("peak_queue", stats["queue"]), ) diff --git a/beantester/settings.py b/beantester/settings.py index 6ce7e56..aaa6f4a 100644 --- a/beantester/settings.py +++ b/beantester/settings.py @@ -24,6 +24,7 @@ loss=0, corrupt=0, dup=0, latency=0, jitter=0, down=0, up=0, buffer=1000, # link buffer (ms) for the speed limit; 0 = unbounded. See fields.py filter="both", target="", dst_ip="", dst_port="", lan_mode=False, + internet_only=False, # the mirror of lan_mode; loopback survives both block_ip="", block_port="", # firewall: drop traffic to matching IP/port syn_drop=0, max_size=0, spike_prob=0, spike_ms=0, @@ -505,6 +506,14 @@ def apply_settings(engine, s, log=lambda *_: None): log(f"{T('log.filter_skipped')}: {e}") engine.set_dest(False) engine.set_lan(bool(g("lan_mode"))) + engine.set_internet_only(bool(g("internet_only"))) + # Both at once is a legal request - it is the union of two impairments, the + # same as --loss 100 - but it is far more likely to be a mistake, and the + # symptom (nothing but loopback moves) looks like the tool is broken rather + # than like the tool doing what it was told. Said out loud, once per apply, + # instead of being refused: refusing would break a run somebody meant. + if g("lan_mode") and g("internet_only"): + log(T("log.lan_and_internet_only")) block_ip = setting_expression("block_ip", g("block_ip")) block_port = setting_expression("block_port", g("block_port")) try: diff --git a/beantester/summary.py b/beantester/summary.py index efd3e96..da50141 100644 --- a/beantester/summary.py +++ b/beantester/summary.py @@ -57,6 +57,8 @@ def settings_summary(s, lang=None, prefix_key="summary.prefix"): parts.append(tr("summary.flap", v=num("flap_period"))) if g("lan_mode"): parts.append(tr("summary.lan")) + if g("internet_only"): + parts.append(tr("summary.internet_only")) if scheduled: parts.append(tr("summary.schedule")) if str(g("target")).strip(): diff --git a/beantester/utils.py b/beantester/utils.py index b841d68..3efaaf3 100644 --- a/beantester/utils.py +++ b/beantester/utils.py @@ -114,6 +114,10 @@ def is_local_ip(ip: Any) -> bool: """True for local addresses (RFC1918, loopback, link-local, CGNAT...). Public (internet) addresses return False. Missing/error = treated as local. + + 🔴 This one COUNTS LOOPBACK as local; ``is_lan_ip`` below does not, and the + difference is deliberate - see its docstring before assuming one of them is + a typo for the other. """ if not ip: return True @@ -124,6 +128,37 @@ def is_local_ip(ip: Any) -> bool: return True +def is_lan_ip(ip: Any) -> bool: + """True for the local network, with LOOPBACK carved out. + + What "Internet only" cuts (``core.decide`` step 2b). It is deliberately NOT + the mirror of ``is_local_ip``: a machine talking to itself on 127.x is not + "the local network" in any sense a tester means, and cutting it would take + down local development servers and any tool using loopback for IPC - on the + same machine the person is running this from. Owner's decision, 2026-08-19. + + Everything the other predicate calls local IS local here (RFC1918, + link-local, CGNAT, and the TEST-NET documentation ranges, which are local + because they are not globally routable). MEASURED, because it is the + surprising half: IPv4 MULTICAST is NOT - ``ipaddress`` reports 224.0.0.251 + as globally routable, so mDNS and SSDP sit on the internet side of both + switches, and LAN mode is the one that cuts them. + + Errors and missing values return False: an address this cannot classify is + an address this must not damage, which is the same safe direction + ``is_local_ip`` takes by answering "local" (there, "local" means "do not + cut"; here it is "not LAN" that means it). + """ + if not ip: + return False + try: + import ipaddress + address = ipaddress.ip_address(str(ip)) + return not address.is_global and not address.is_loopback + except Exception: + return False + + def _route_source_ip(family: int, probe: str) -> str: """The local address the OS would use to reach ``probe`` - no packet is sent. diff --git a/lang/en.json b/lang/en.json index af1f8c1..fdf3b3d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -183,6 +183,7 @@ "fields.expects_number_range": "a number between {min} and {max}", "fields.filter": "Filter", "fields.flap_down_pct": "Downtime percent:", + "fields.internet_only": "Internet only (no local network)", "fields.ip": "IP:", "fields.jitter": "Jitter:", "fields.lan_mode": "LAN mode (local network only, no internet)", @@ -271,6 +272,7 @@ "log.engine_fault": "Engine fault: {e} - the session was stopped, your network is back to normal.", "log.error": "Error", "log.filter_skipped": "This expression could not be read, so it was switched off for this session", + "log.lan_and_internet_only": "LAN mode and Internet only are both on - nothing but loopback gets through.", "log.layout_reset": "Window layout reset.", "log.loaded_profile": "Loaded profile", "log.loop": "loop", @@ -345,7 +347,7 @@ "prefs.reset_layout": "Reset window layout", "prefs.restore_profile": "Restore the last profile on startup", "prefs.scope_view": "Show only the targeted traffic", - "prefs.scope_view_hint": "Statistics, the throughput chart, the Connections table and both CSV exports then cover only what your targeting selected. It changes what you SEE - never what is captured and never what is impaired.", + "prefs.show_control_search": "Show the search box on the Control page", "prefs.unit_lines": "lines", "prefs.unit_seconds": "s", "presets.3g": "3G network", @@ -392,6 +394,7 @@ "stats.duplicated": "Duplicated", "stats.flap_cut": "Link outage", "stats.lan_cut": "LAN: internet cut", + "stats.local_cut": "Local network cut", "stats.mtu_dropped": "MTU dropped", "stats.nat_expired": "NAT expired", "stats.overflow": "Buffer overflow", @@ -415,6 +418,7 @@ "summary.down": "download <= {v} KB/s", "summary.dup": "{v}% duplicates", "summary.flap": "outages every {v} s", + "summary.internet_only": "internet only (no local network)", "summary.jitter": "jitter +/-{v} ms", "summary.lan": "LAN mode (no internet)", "summary.latency": "+{v} ms ping", @@ -492,6 +496,7 @@ "tips.filter": "Which traffic to modify. 'Both directions' covers TCP, UDP and ping (ICMP). The other options narrow it down. For a single port or address use 'Target destination' below. Locked while running (STOP to change).", "tips.flap": "Cyclic full outages: every 'Period' seconds the link is dead for the given percent of the time. Simulates a flapping connection (e.g. weak WiFi).", "tips.freeze": "Freeze the table so rows stop moving while you inspect or copy them.", + "tips.internet_only": "Cuts the local network and leaves the internet up: traffic to and from local addresses (10.x, 192.168.x, 172.16-31.x, link-local, CGNAT) is dropped. Loopback (127.x) keeps working, so programs talking to themselves on this machine are left alone. Careful: if your PC asks the router for DNS, the internet stops working too, because those queries are local traffic.", "tips.jitter": "Random delay variation (+/- ms), drawn separately for every packet. Ping starts to jump instead of being steady. It also lightly reorders packets. The request and the reply each get their own draw, so the wobble on ping is wider than this number - about 1.4x usually, up to 2x at the extremes.", "tips.lan_mode": "Simulates a network with no internet access: traffic to/from public addresses is dropped, while the local network (LAN: 10.x, 192.168.x, 172.16-31.x, loopback) works. Tests how the app behaves when the internet is down but the intranet is up.", "tips.language": "Interface language. Switching rebuilds the UI but keeps the current session and settings. Locked while running.", @@ -524,6 +529,7 @@ "tips.seed": "Random seed. Set any number so every run randomizes identically - this makes a bug reproducible. Empty = different every time.", "tips.session_capture": "Which traffic the driver handed over during this session. \"Narrowed to the destination\" means the counters and the connection list cover that traffic only, because the rest never reached the tool.", "tips.settings": "App settings: interface language and how many rows the tables show.", + "tips.show_control_search": "Shows the \"Search\" box at the top of the Control page. With it off the box is gone and Ctrl+F takes you to the search box in the Connections tab instead.", "tips.spike": "Occasional ping spikes: with the given probability (%) add extra delay (ms) to a single packet. Reproduces momentary 'lag'.", "tips.start": "Turns traffic modification on/off. Picking a preset or changing fields does nothing on its own - only START begins impairing. Requires running as administrator.", "tips.stat_block": "Packets dropped by a block (firewall) rule.", @@ -532,6 +538,7 @@ "tips.stat_duplicated": "Packets sent twice.", "tips.stat_flap": "Packets dropped by a link outage (flapping). Counted separately from loss.", "tips.stat_lan": "Packets to/from the internet dropped in LAN mode.", + "tips.stat_local": "Packets to/from the local network dropped by \"Internet only\".", "tips.stat_loss": "Packets dropped because of the configured Loss. Link outages are counted separately, under Link outage.", "tips.stat_mtu": "Packets dropped as too large (MTU black hole).", "tips.stat_nat": "Packets dropped after the NAT mapping expired.", diff --git a/lang/pl.json b/lang/pl.json index 7e1eee2..b2dda38 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -183,6 +183,7 @@ "fields.expects_number_range": "liczby od {min} do {max}", "fields.filter": "Filtr", "fields.flap_down_pct": "Procent przerwy w łączu:", + "fields.internet_only": "Tylko internet (bez sieci lokalnej)", "fields.ip": "IP:", "fields.jitter": "Jitter:", "fields.lan_mode": "Tryb LAN (tylko sieć lokalna, bez internetu)", @@ -271,6 +272,7 @@ "log.engine_fault": "Awaria silnika: {e} - sesja została zatrzymana, sieć działa normalnie.", "log.error": "Błąd", "log.filter_skipped": "Nie udało się odczytać tego wyrażenia, więc zostało wyłączone na tę sesję", + "log.lan_and_internet_only": "Tryb LAN i Tylko internet są włączone naraz - poza loopbackiem nic nie przejdzie.", "log.layout_reset": "Układ okna zresetowany.", "log.loaded_profile": "Wczytano profil", "log.loop": "pętla", @@ -345,7 +347,7 @@ "prefs.reset_layout": "Zresetuj układ okna", "prefs.restore_profile": "Przywróć ostatni profil przy starcie", "prefs.scope_view": "Pokazuj tylko ruch celu", - "prefs.scope_view_hint": "Statystyki, wykres przepustowości, tabela Połączeń i oba eksporty CSV obejmują wtedy tylko to, co wybrało Twoje celowanie. Zmienia to, co WIDZISZ - nigdy tego, co jest przechwytywane, ani tego, co jest psute.", + "prefs.show_control_search": "Pokazuj wyszukiwarkę na stronie Sterowanie", "prefs.unit_lines": "linii", "prefs.unit_seconds": "s", "presets.3g": "Sieć 3G", @@ -392,6 +394,7 @@ "stats.duplicated": "Zduplikowane", "stats.flap_cut": "Przerwa w łączu", "stats.lan_cut": "LAN: internet odcięty", + "stats.local_cut": "Sieć lokalna odcięta", "stats.mtu_dropped": "MTU odrzucone", "stats.nat_expired": "NAT wygasło", "stats.overflow": "Bufor przepełn.", @@ -415,6 +418,7 @@ "summary.down": "pobieranie <= {v} KB/s", "summary.dup": "{v}% duplikatów", "summary.flap": "przerwy co {v} s", + "summary.internet_only": "tylko internet (bez sieci lokalnej)", "summary.jitter": "jitter +/-{v} ms", "summary.lan": "tryb LAN (bez internetu)", "summary.latency": "+{v} ms pingu", @@ -492,6 +496,7 @@ "tips.filter": "Który ruch modyfikować. „Ruch w obie strony” obejmuje TCP, UDP i ping (ICMP). Pozostałe opcje zawężają zakres. Konkretny port lub adres ustawisz niżej w „Celuj w cel”. Zablokowane w trakcie działania (zmiana wymaga STOP).", "tips.flap": "Cykliczne całkowite zrywanie ruchu: co 'Okres' sekund łącze jest martwe przez podany procent czasu. Symuluje migające połączenie (np. słabe WiFi).", "tips.freeze": "Zamroź tabelę, żeby wiersze nie uciekały podczas przeglądania i kopiowania.", + "tips.internet_only": "Odcina sieć lokalną, a zostawia internet: ruch do i od adresów lokalnych (10.x, 192.168.x, 172.16-31.x, link-local, CGNAT) jest odrzucany. Loopback (127.x) działa dalej, więc programy rozmawiające same ze sobą na tej maszynie zostają nietknięte. Uwaga: jeśli komputer pyta o DNS router, internet też przestanie działać, bo takie zapytania to ruch lokalny.", "tips.jitter": "Losowe wahanie opóźnienia (+/- ms), losowane osobno dla każdego pakietu. Ping zaczyna skakać zamiast być stały. Powoduje też lekkie mieszanie kolejności pakietów. Zapytanie i odpowiedź losują niezależnie, więc wahania pingu są szersze niż ta liczba - zwykle około 1,4x, w skrajności 2x.", "tips.lan_mode": "Symuluje sieć bez dostępu do internetu: ruch do/od adresów publicznych jest odrzucany, a sieć lokalna (LAN: 10.x, 192.168.x, 172.16-31.x, loopback) działa. Test zachowania aplikacji, gdy internet jest niedostępny, a intranet tak.", "tips.language": "Język interfejsu. Przełączenie przebudowuje UI, ale zachowuje bieżącą sesję i ustawienia. Zablokowane w trakcie działania.", @@ -524,6 +529,7 @@ "tips.seed": "Ziarno losowości. Ustaw dowolną liczbę, aby każdy przebieg losował tak samo - dzięki temu błąd da się odtworzyć. Puste = za każdym razem inaczej.", "tips.session_capture": "Jaki ruch sterownik podawał w tej sesji. „Zawężony do celu” znaczy, że liczniki i lista połączeń obejmują wyłącznie ten ruch, bo reszta w ogóle nie dotarła do narzędzia.", "tips.settings": "Ustawienia aplikacji: język interfejsu i ile wierszy pokazują tabele.", + "tips.show_control_search": "Pokazuje pole „Szukaj” u góry strony Sterowanie. Po wyłączeniu pole znika, a Ctrl+F przenosi do wyszukiwarki w zakładce Połączenia.", "tips.spike": "Sporadyczne skoki pingu: z podanym prawdopodobieństwem (%) doklej dodatkowe opóźnienie (ms) do pojedynczego pakietu. Odwzorowuje chwilowe 'lagi'.", "tips.start": "Włącza/wyłącza modyfikowanie ruchu. Wybór presetu lub zmiana pól sama nic nie robi - dopiero START uruchamia zakłócenia. Wymaga uruchomienia jako administrator.", "tips.stat_block": "Pakiety odrzucone przez regułę blokady (firewall).", @@ -532,6 +538,7 @@ "tips.stat_duplicated": "Pakiety wysłane podwójnie.", "tips.stat_flap": "Pakiety porzucone przez przerwę w łączu (flapping). Liczone osobno od strat.", "tips.stat_lan": "Pakiety do/od internetu odrzucone w trybie LAN.", + "tips.stat_local": "Pakiety do/od sieci lokalnej odrzucone przez „Tylko internet”.", "tips.stat_loss": "Pakiety porzucone z powodu ustawionej Utraty. Przerwy w łączu mają własny licznik - Przerwa w łączu.", "tips.stat_mtu": "Pakiety odrzucone jako za duże (czarna dziura MTU).", "tips.stat_nat": "Pakiety odrzucone po wygaśnięciu mapowania NAT.", diff --git a/tests/test_cli_runtime.py b/tests/test_cli_runtime.py index 9b2e1e2..b830bbe 100644 --- a/tests/test_cli_runtime.py +++ b/tests/test_cli_runtime.py @@ -215,6 +215,7 @@ def set_params(self, *_a, **_k): pass def set_buffer(self, *_a, **_k): pass def set_dest(self, *_a, **_k): pass def set_lan(self, *_a, **_k): pass + def set_internet_only(self, *_a, **_k): pass def set_block(self, *_a, **_k): pass def set_advanced(self, *_a, **_k): pass def set_spike(self, *_a, **_k): pass @@ -374,6 +375,7 @@ def set_params(self, *_a, **_k): pass def set_buffer(self, *_a, **_k): pass def set_dest(self, *_a, **_k): pass def set_lan(self, *_a, **_k): pass + def set_internet_only(self, *_a, **_k): pass def set_block(self, *_a, **_k): pass def set_advanced(self, *_a, **_k): pass def set_spike(self, *_a, **_k): pass @@ -1046,6 +1048,34 @@ def test_the_warning_names_lan_mode_which_reads_like_a_scope(monkeypatch): check("warning: LAN mode alone is a machine-wide impairment", _warned(err)) +def test_the_warning_names_internet_only_too(monkeypatch): + """Its mirror cuts the machine's local network the same way, and the name + reads just as much like a scope ("only the internet") as LAN mode's does. + + The registry is what makes this true - the field declares ``IMPAIRS_ALL`` - + but a declaration nobody exercises is how the first one got missed, so the + second gate is asked the question rather than assumed to inherit the answer. + """ + _, _, err, _ = _real_run(monkeypatch, ["--internet-only"]) + check("warning: Internet only alone is a machine-wide impairment", _warned(err)) + + +def test_the_lan_abbreviation_still_reaches_lan_mode(monkeypatch): + """🔴 MEASURED, and the reason the new flag is not called ``--lan-cut``. + + argparse keeps ``allow_abbrev`` on here by decision (ADR 2026-08-02: people + may already be typing ``--lat``), so a SECOND option starting with ``lan-`` + would make ``--lan`` ambiguous and argparse would refuse it outright with + exit 2 - silently breaking a shortcut of a documented flag. This asserts the + abbreviation still resolves, which is the property the naming protects. + """ + parser = cli_module.build_arg_parser() + args = parser.parse_args(["--lan"]) + check("--lan still means --lan-mode", args.lan_mode is True) + check("--internet-only did not attach itself to it", + args.internet_only is False) + + def test_a_bounded_run_is_not_warned_about(monkeypatch): """Three ways to bound a run, and each one has to buy silence. diff --git a/tests/test_core.py b/tests/test_core.py index ce45da9..a29f5ca 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -276,6 +276,75 @@ def test_lan_mode_gate(): check("LAN: disabled = internet passes", not off.drop, f"(drop={off.drop})") +def test_is_lan_ip_carves_out_loopback(): + """The predicate behind "Internet only", and the two answers that surprise. + + LOOPBACK is not the local network here (owner's decision, 2026-08-19): a + machine talking to itself is not what a tester means by "the LAN", and + cutting it would take down local development servers on the very machine the + tool runs on. MULTICAST is not either, and that one is measured rather than + chosen - ``ipaddress`` reports 224.0.0.251 as globally routable, so mDNS and + SSDP sit on the internet side of both switches. + """ + from beantester.utils import is_lan_ip + for ip in ("192.168.1.10", "10.0.0.5", "172.20.1.1", "169.254.3.4", + "100.64.0.1", "203.0.113.9", "fe80::1", "::ffff:192.168.1.1"): + check(f"lan: {ip} is the local network", is_lan_ip(ip)) + for ip in ("127.0.0.1", "127.9.9.9", "::1", "::ffff:127.0.0.1"): + check(f"lan: {ip} is loopback, not the local network", not is_lan_ip(ip)) + for ip in ("8.8.8.8", "1.1.1.1", "224.0.0.251"): + check(f"lan: {ip} is not the local network", not is_lan_ip(ip)) + # Anything unclassifiable must not be damaged - the same safe direction + # is_local_ip takes by answering "local". + for bad in (None, "", "not-an-ip", "999.1.1.1"): + check(f"lan: {bad!r} is not treated as the local network", not is_lan_ip(bad)) + + +def test_internet_only_gate(): + """The mirror of LAN mode: cut the local network, keep the internet.""" + core = BeanCore() + core.set_internet_only(True) + rng = random.Random(1) + loc = core.decide(200, True, 5000, 0.0, rng, remote_ip="192.168.1.5", remote_port=443) + pub = core.decide(200, True, 5000, 0.0, rng, remote_ip="8.8.8.8", remote_port=443) + back = core.decide(200, True, 5000, 0.0, rng, remote_ip="127.0.0.1", remote_port=443) + none = core.decide(200, True, 5000, 0.0, rng, remote_ip=None, remote_port=443) + six = core.decide(200, True, 5000, 0.0, rng, remote_ip="fe80::5", remote_port=443) + check("internet only: local traffic dropped", + loc.drop and loc.reason == "internet_only", + f"(drop={loc.drop}, reason={loc.reason})") + check("internet only: internet passes", not pub.drop, f"(drop={pub.drop})") + check("internet only: LOOPBACK passes", not back.drop, f"(drop={back.drop})") + check("internet only: a packet with no remote address passes", not none.drop) + check("internet only: IPv6 link-local is the local network too", + six.drop and six.reason == "internet_only", f"(reason={six.reason})") + core.set_internet_only(False) + off = core.decide(200, True, 5000, 0.0, rng, remote_ip="192.168.1.5", remote_port=443) + check("internet only: disabled = local passes", not off.drop, f"(drop={off.drop})") + + +def test_both_lan_switches_split_the_traffic_instead_of_one_swallowing_it(): + """Both on is legal - the union of two impairments - and each names its half. + + They judge the same packet from opposite sides, so they can never both fire + on one packet. That is why nothing but loopback survives and why the two + counters stay meaningful: one says how much internet was cut, the other how + much local network was. + """ + core = BeanCore() + core.set_lan(True) + core.set_internet_only(True) + rng = random.Random(1) + pub = core.decide(200, True, 5000, 0.0, rng, remote_ip="8.8.8.8", remote_port=443) + loc = core.decide(200, True, 5000, 0.0, rng, remote_ip="192.168.1.5", remote_port=443) + back = core.decide(200, True, 5000, 0.0, rng, remote_ip="127.0.0.1", remote_port=443) + check("both: the internet half is named by LAN mode", + pub.drop and pub.reason == "lan", f"(reason={pub.reason})") + check("both: the local half is named by Internet only", + loc.drop and loc.reason == "internet_only", f"(reason={loc.reason})") + check("both: loopback still gets through", not back.drop, f"(drop={back.drop})") + + class _FakePorts: """A live port container the way ``ProcessTargeting`` presents itself. diff --git a/tests/test_core_properties.py b/tests/test_core_properties.py index c3706e2..0e289f8 100644 --- a/tests/test_core_properties.py +++ b/tests/test_core_properties.py @@ -203,7 +203,7 @@ def test_every_decision_is_structurally_coherent(s, pkts): # gate on an otherwise maximally noisy core, so whatever comes out names the # step that fired. If a step is ever moved, inserted or made conditional, the # reason changes and this goes red. -GATES = ["lan", "block", "nat", "rst", "flap", "mtu", "syn"] +GATES = ["lan", "internet_only", "block", "nat", "rst", "flap", "mtu", "syn"] # Impairments that must NOT be able to preempt a gate. All of them sit at step 7 # or later, so at 100% they are the strongest possible competition. @@ -222,6 +222,8 @@ def _armed(gate, rst_cooldown, flap_period): core.reset_buckets(0.0) if gate == "lan": core.set_lan(True) + elif gate == "internet_only": + core.set_internet_only(True) elif gate == "block": core.set_block(True, port="443") elif gate == "nat": @@ -261,8 +263,12 @@ def test_an_armed_gate_wins_over_every_later_step(gate, seed, size, # Measured 2026-07-28: such a reset left the client hanging until its own # timeout instead of resetting it. So the rst gate is armed with an ordinary # packet here; the syn gate needs a SYN by definition, and the rest do not care. - kw = dict(remote_ip="8.8.8.8", remote_port=443, is_tcp=True, - is_syn=(gate != "rst")) + # 🔴 Two gates here judge the remote end by ADDRESS CLASS and want OPPOSITE + # answers: LAN mode fires on a public peer, "Internet only" on a local one. + # One address for every gate would leave the second one unarmed and this test + # green for the wrong reason. + kw = dict(remote_ip="192.168.1.5" if gate == "internet_only" else "8.8.8.8", + remote_port=443, is_tcp=True, is_syn=(gate != "rst")) now = 0.0 if gate == "nat": core.decide(100, True, 5000, 0.0, rng, **kw) # create the mapping... @@ -284,6 +290,14 @@ def test_an_earlier_gate_beats_a_later_one(seed): check("LAN mode (2b) wins over the MTU black hole (6)", d.drop and d.reason == "lan", f"(reason={d.reason})") + mirror = BeanCore() + mirror.set_internet_only(True) # step 2b, the other half + mirror.set_advanced(0, 10) # step 6 - would also drop this packet + m = mirror.decide(9000, False, 5000, 0.0, random.Random(seed), + remote_ip="192.168.1.5", remote_port=443, is_tcp=True) + check("Internet only (2b) wins over the MTU black hole (6)", + m.drop and m.reason == "internet_only", f"(reason={m.reason})") + # --------------------------------------------------------------------------- # # P4: an unnamed drop is the signature of packet loss, and of nothing else diff --git a/tests/test_gui_layout.py b/tests/test_gui_layout.py index 533b077..ddc2f81 100644 --- a/tests/test_gui_layout.py +++ b/tests/test_gui_layout.py @@ -545,6 +545,16 @@ def test_one_ctrl_f_reaches_whichever_search_box_is_in_front(): focus_search(app) assert app.current_page() is conns, "from a page with no box it must fall back" assert root.focus_get() is conns._search_entry + + # The Control page's box can be switched OFF in the Settings window, and + # then it is a page without a box: focusing a widget that is not on + # screen would swallow the keystrokes that followed the shortcut. + app.select_page("control") + app.set_pref("show_control_search", False) + control.on_pref_changed("show_control_search") + focus_search(app) + assert app.current_page() is conns, "a hidden box kept Ctrl+F to itself" + assert root.focus_get() is conns._search_entry """) diff --git a/tests/test_gui_release_fixes.py b/tests/test_gui_release_fixes.py index 5f79a80..ed73e0c 100644 --- a/tests/test_gui_release_fixes.py +++ b/tests/test_gui_release_fixes.py @@ -466,3 +466,29 @@ def test_the_banner_also_fires_when_the_tool_cannot_re_inject(): app._drain_engine_warning() assert app.engine_warning.kw.get("text") == "" """) + + +def test_the_statistics_copy_menu_is_dark_like_every_other_context_menu(): + """It came up WHITE in the middle of a dark program - reported from a running + build, 2026-08-19. + + ttk styles do not reach a classic ``tk.Menu``: on Windows it is a native Win32 + popup, so it keeps the system colours unless something configures it. The + connection table wraps its menu in ``theme.style_menu``; this one was built + bare. Nothing could go red over it, because the fake tkinter records colours + and never renders them - which is why the rule guard in + ``test_repo_conventions.py`` was added beside this test rather than instead + of it: this one proves THIS menu is dark, that one proves the next menu + somebody adds cannot repeat the mistake. + """ + run_gui(""" + from beantester.gui.theme import ACC, BG2, FG + page = app.pages["statistics"] + menu = page._copy_menu() + assert menu.cget("background") == BG2, menu.kw + assert menu.cget("foreground") == FG, menu.kw + assert menu.cget("activebackground") == ACC, menu.kw + # the cached menu is the SAME object on the second call, so a second + # right-click cannot get an unstyled one + assert page._copy_menu() is menu + """) diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index cf65046..5ca2dc1 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -1221,6 +1221,111 @@ "new": 'module = ["beantester.gui.rates", "beantester.gui.scope"]', "test": "test_the_strictly_typed_modules_only_ever_grow", }, + { + # How the white menu got in: one of the two menus in the program was + # built bare. The rule guard reads the source, so this is the patch it + # has to see. + "label": "gui: a context menu is built without the dark theme", + "file": "beantester/gui/pages/stats.py", + "old": "menu = style_menu(tk.Menu(self.frame, tearoff=0))", + "new": "menu = tk.Menu(self.frame, tearoff=0)", + "test": "test_every_context_menu_is_handed_to_the_dark_theme", + }, + { + # The other half, and the reason both exist: "style_menu was called" and + # "the menu is dark" are two claims. This one breaks the wrapper while + # leaving every call site intact, so only the behavioural test can see it. + "label": "gui: the menu theme stops setting a background", + "file": "beantester/gui/theme.py", + "old": " menu.configure(background=BG2, foreground=FG,", + "new": " menu.configure(foreground=FG,", + "test": "test_the_statistics_copy_menu_is_dark_like_every_other_context_menu", + }, + { + # pack hands out space in CALL order, so the bar comes back UNDER the + # whole page body. The fake cannot render it - it can only see that the + # call no longer says where to sit. + "label": "gui: the search bar comes back without saying where to sit", + "file": "beantester/gui/pages/control.py", + "old": " self._pack_bar(before=self.scroll.vsb)", + "new": " self._pack_bar()", + "test": "test_the_control_search_bar_can_be_switched_off_and_back_on", + }, + { + # The marks live on the FORM, so hiding the bar without clearing leaves + # fields highlighted with nothing left to clear them from. + "label": "gui: hiding the search leaves its marks on the form", + "file": "beantester/gui/pages/control.py", + "old": ' self.query_var.set("")\n' + " self._apply() # unmarks, refolds, forgets the query", + "new": " pass", + "test": "test_hiding_the_search_takes_its_marks_and_its_folds_with_it", + }, + { + # Focusing a widget that is not on screen swallows whatever the user + # types next - the shortcut has to decline instead. + "label": "gui: Ctrl+F still claims a hidden search box", + "file": "beantester/gui/pages/control.py", + "old": " if not self._search_shown:\n return False", + "new": " pass", + "test": "test_one_ctrl_f_reaches_whichever_search_box_is_in_front", + }, + { + # Text written, translated and reviewed, then drawn by nobody: the BOOL + # row returns before the hint. The field registry has had this guard for + # a while; the pref registry did not, and lost a paragraph to it. + "label": "prefs: a checkbox declares a hint its row cannot draw", + "file": "beantester/gui/prefs.py", + "old": ' default=False, section="scope"),', + "new": ' default=False, hint="prefs.scope_view", section="scope"),', + "test": "test_only_prefs_that_can_show_a_hint_declare_one", + }, + { + "label": "core: the Internet-only gate stops cutting the local network", + "file": "beantester/core.py", + "old": " if self.internet_only and is_lan_ip(remote_ip):\n" + ' return "internet_only"', + "new": " pass", + "test": "test_internet_only_gate", + }, + { + # The carve-out the owner asked for. Without it the switch takes down the + # local development server on the machine running the tool. + "label": "utils: loopback stops being carved out of the local network", + "file": "beantester/utils.py", + "old": " return not address.is_global and not address.is_loopback", + "new": " return not address.is_global", + "test": "test_is_lan_ip_carves_out_loopback", + }, + { + # Without its own row the drop falls through to the unnamed default and + # is reported as packet LOSS - the exact confusion drop_flap was split + # out to end. + "label": "engine: the Internet-only drop loses its own counter", + "file": "beantester/engine.py", + "old": ' "internet_only": "drop_internet_only", "block": "drop_block",', + "new": ' "block": "drop_block",', + "test": "test_every_drop_counter_and_drop_reason_is_classified", + }, + { + # Both switches on cuts everything but loopback. Silence there looks like + # a broken tool rather than a tool doing as it was told. + "label": "settings: both LAN switches on stops saying so", + "file": "beantester/settings.py", + "old": ' log(T("log.lan_and_internet_only"))', + "new": " pass", + "test": "test_both_lan_switches_at_once_are_allowed_and_said_out_loud", + }, + { + # The hand-written list falling behind the registry: the command then + # reproduces a DIFFERENT run, with nothing red to say so. That is how + # --narrow-filter went missing for weeks. + "label": "repro: a flag drops out of the reproduction command", + "file": "beantester/repro.py", + "old": ' if g("internet_only"):\n args += ["--internet-only"]', + "new": " pass", + "test": "test_every_setting_with_a_flag_reaches_the_reproduction_command", + }, ] # The runner's own check: a patch that cannot compile must be reported as BROKEN, not diff --git a/tests/test_passthrough.py b/tests/test_passthrough.py index 268fc6a..9b40509 100644 --- a/tests/test_passthrough.py +++ b/tests/test_passthrough.py @@ -196,7 +196,8 @@ def test_default_core_is_pass_through_across_a_deterministic_sweep(): # --------------------------------------------------------------------------- # # Layer B: a real engine session forwards everything while still collecting # --------------------------------------------------------------------------- # -DAMAGE_COUNTERS = ["drop_loss", "drop_flap", "drop_lan", "drop_block", "drop_mtu", +DAMAGE_COUNTERS = ["drop_loss", "drop_flap", "drop_lan", "drop_internet_only", + "drop_block", "drop_mtu", "drop_nat", "drop_rst", "drop_syn", "drop_overflow", "drop_rate", "corrupted", "duplicated", "rst_sent"] diff --git a/tests/test_prefs.py b/tests/test_prefs.py index ad6c9fa..674e71f 100644 --- a/tests/test_prefs.py +++ b/tests/test_prefs.py @@ -58,6 +58,27 @@ def test_a_pref_can_only_name_a_section_that_will_actually_render_it(): p in prefs.prefs_in_section(p.section)) +def test_only_prefs_that_can_show_a_hint_declare_one(): + """A hint on a checkbox is text nobody will ever read. + + ``SettingsWindow._build_pref_row`` handles BOOL and ACTION first and + **returns**, so only a NUMBER row ever reaches the line that draws + ``pref.hint``. Nothing raises: the text is simply written, translated into + every language, and then shown to no one. + + Measured, not hypothetical. ``scope_view_to_target`` carried a 250-character + hint that never appeared on screen - and its TOOLTIP had grown into the + longest string in the language files trying to carry the same explanation. + That is word for word what + ``test_field_registry.py::test_only_fields_that_can_show_a_hint_declare_one`` + already records about ``narrow_filter``: the same hole, one registry over, + which is why this guard is its mirror rather than a new idea. + """ + stray = [(p.key, p.kind) for p in PREFS if p.hint and p.kind != prefs.NUMBER] + check("prefs: no pref declares a hint its row cannot show", + not stray, f"({stray})") + + def test_pref_texts_resolve_in_every_language(): keys = [] for p in PREFS: @@ -323,3 +344,111 @@ def test_reset_ui_layout_forgets_window_state(): assert app.ui.get("collapsed") == [] assert app.collapsed_sections == [] """) + + +def test_the_control_search_bar_can_be_switched_off_and_back_on(): + """The box goes away, and comes back ABOVE the page body. + + The order is the part that can go wrong silently: pack hands out space in + CALL order, so a bar re-packed after the scroller exists lands UNDER the whole + page unless it names what to sit before. This asserts the call carries + ``before=``; whether Tk then draws it in the right place is a live-render + question the fake cannot answer (it keeps children in creation order). + """ + run_gui(""" + page = app.pages["control"] + assert app.pref("show_control_search") is True, "default is: the box is there" + assert page._bar.winfo_ismapped(), "the bar should start on the page" + + app.set_pref("show_control_search", False) + page.on_pref_changed("show_control_search") + assert not page._bar.winfo_ismapped(), "the bar is still on the page" + + app.set_pref("show_control_search", True) + page.on_pref_changed("show_control_search") + assert page._bar.winfo_ismapped(), "the bar did not come back" + assert page._bar.pack_info.get("before") is page.scroll.vsb, ( + "re-packed without before= - it would sit under the page body") + + # an unrelated preference must not move it + page.on_pref_changed("chart_seconds") + assert page._bar.winfo_ismapped() + """) + + +def test_hiding_the_search_takes_its_marks_and_its_folds_with_it(): + """The marks are painted on the FORM, not on the bar. + + So a query left standing when the box goes away leaves fields highlighted + with nothing left to clear them from, and the sections the search unfolded + stay unfolded. A debounce still in flight would repaint both a moment after + the bar was gone, which is why the pending job is cancelled rather than left + to run against a hidden box. + """ + run_gui(""" + page = app.pages["control"] + page.query_var.set("port") + page._apply() + assert page._targets, "the fixture needs a query that actually matches" + marked = [w for w, _kind, _old in page._marks] + assert marked + + # A debounce in flight. The fake's `after` returns nothing, so the job is + # planted by hand and the cancel is recorded - what matters is that the + # timer is taken back, not how the fake numbers it. + cancelled = [] + page._job = "pending-search" + page.frame.after_cancel = lambda job: cancelled.append(job) + + app.set_pref("show_control_search", False) + page.on_pref_changed("show_control_search") + + assert cancelled == ["pending-search"], cancelled + assert page._job is None, "a pending search would repaint a hidden box" + assert page.query_var.get() == "" + assert page._marks == [], "fields left highlighted with no box to clear them" + assert not page._opened, "sections the search opened stayed open" + for widget in marked: + assert not str(widget.cget("style")).startswith("Hit"), widget.kw + """) + + +def test_a_hidden_search_bar_survives_a_language_switch(): + """The page is REBUILT from scratch by a language switch, and the rebuild + packs the bar before anything reads the preference - so the state has to be + re-applied, not assumed.""" + run_gui(""" + app.set_pref("show_control_search", False) + app.pages["control"].on_pref_changed("show_control_search") + + app.lang_var.set(app._lang_name2code and "English" or "English") + app._switch_language() + + page = app.pages["control"] + assert not page._bar.winfo_ismapped(), "the rebuilt page brought the bar back" + assert page.focus_search() is False + """) + + +def test_the_settings_checkbox_hides_the_search_immediately(): + """End to end, through the window the user actually clicks. + + Storing alone is not enough here: every other preference is re-read by the + next tick anyway, but a widget appearing or disappearing up to 0.7 s after + the click reads as a broken checkbox. This is the path that closes that gap + (``SettingsWindow._store`` -> ``gui/pages/pref_changed``). + """ + run_gui(""" + page = app.pages["control"] + panel = app.open_window("settings") + var = panel._pref_vars["show_control_search"] + + var.set(False) + panel._store("show_control_search", False) + assert app.pref("show_control_search") is False, "not persisted" + assert not page._bar.winfo_ismapped(), "still on the page after the click" + + var.set(True) + panel._store("show_control_search", True) + assert page._bar.winfo_ismapped(), "the box did not come back" + """) diff --git a/tests/test_remote_endpoint_directions.py b/tests/test_remote_endpoint_directions.py index 785b813..ce43548 100644 --- a/tests/test_remote_endpoint_directions.py +++ b/tests/test_remote_endpoint_directions.py @@ -1,10 +1,10 @@ """Every gate that judges the REMOTE end must judge it in BOTH directions. The invariant: ``engine._capture_loop`` reads the remote endpoint as the packet's -DESTINATION when it is outbound and as its SOURCE when it is inbound. Five -features consume that value - destination IP, destination port, LAN mode, block -by IP, block by port - and each of them is meant to act on "who the other end -is", not on "which header field happens to hold it". +DESTINATION when it is outbound and as its SOURCE when it is inbound. Six +features consume that value - destination IP, destination port, LAN mode, +"Internet only", block by IP, block by port - and each of them is meant to act on +"who the other end is", not on "which header field happens to hold it". Why this file exists rather than one more case in an existing test. MUTATION, 2026-07-29: changing the inbound branch to read ``dst_addr`` - the exact @@ -68,35 +68,48 @@ def _inbound_from(peer, port=PEER_PORT): dst_port=LOCAL_PORT, src_addr=peer, dst_addr="10.0.0.2") -# (name, how to configure the engine, which counter should move) +# A peer on the local network, for the gate that cuts exactly that. It must not +# be loopback: "Internet only" carves 127.x out on purpose (utils.is_lan_ip), so +# reaching for 127.0.0.1 here would make the gate do nothing and read as a bug in +# it - the same trap the note above records for the TEST-NET ranges. +LAN_PEER = "192.168.77.7" + +# (name, how to configure the engine, which counter should move, the peer to send +# to, and whether the gate judges an ADDRESS CLASS rather than a specific peer) +# +# That last flag used to be `if name == "LAN mode"` further down - a check on the +# NAME, which is the shape this file's own docstring argues against. With a +# second address-class gate it would simply have been wrong. CONSUMERS = ( ("destination IP", - lambda e: e.set_dest(True, PEER, ""), "drop_loss"), + lambda e: e.set_dest(True, PEER, ""), "drop_loss", PEER, False), ("destination port", - lambda e: e.set_dest(True, "", str(PEER_PORT)), "drop_loss"), + lambda e: e.set_dest(True, "", str(PEER_PORT)), "drop_loss", PEER, False), ("LAN mode", - lambda e: e.set_lan(True), "drop_lan"), + lambda e: e.set_lan(True), "drop_lan", PEER, True), + ("Internet only", + lambda e: e.set_internet_only(True), "drop_internet_only", LAN_PEER, True), ("block by IP", - lambda e: e.set_block(True, PEER, ""), "drop_block"), + lambda e: e.set_block(True, PEER, ""), "drop_block", PEER, False), ("block by port", - lambda e: e.set_block(True, "", str(PEER_PORT)), "drop_block"), + lambda e: e.set_block(True, "", str(PEER_PORT)), "drop_block", PEER, False), ) def test_every_remote_endpoint_gate_fires_in_both_directions(): """Outbound TO the peer and inbound FROM the peer must be treated alike.""" - for name, configure, counter in CONSUMERS: + for name, configure, counter, peer, _class_gate in CONSUMERS: def setup(engine, configure=configure, counter=counter): configure(engine) if counter == "drop_loss": # destination targeting only SELECTS; something has to do damage engine.set_params(100, 0, 0, 0, 0, 0, 0) - out = _run(setup, [_outbound_to(PEER)], counter) + out = _run(setup, [_outbound_to(peer)], counter) check("%s: an OUTBOUND packet to the peer is caught" % name, out == 1, "(%s=%s)" % (counter, out)) - inn = _run(setup, [_inbound_from(PEER)], counter) + inn = _run(setup, [_inbound_from(peer)], counter) check("%s: an INBOUND packet from the peer is caught too" % name, inn == 1, "(%s=%s)" % (counter, inn)) @@ -106,9 +119,9 @@ def test_the_gates_still_let_a_different_peer_through_in_both_directions(): Without this, a gate that simply said "yes" would satisfy the test above. """ - for name, configure, counter in CONSUMERS: - if name == "LAN mode": - continue # LAN mode gates on "is it public", not on which peer + for name, configure, counter, _peer, class_gate in CONSUMERS: + if class_gate: + continue # these gate on the address CLASS, not on which peer def setup(engine, configure=configure, counter=counter): configure(engine) @@ -123,3 +136,36 @@ def setup(engine, configure=configure, counter=counter): inn = _run(setup, [_inbound_from(OTHER, other_port)], counter) check("%s: an unrelated INBOUND packet is left alone" % name, inn == 0, "(%s=%s)" % (counter, inn)) + + +def test_the_address_class_gates_let_the_other_class_through_in_both_directions(): + """The half the "different peer" test above cannot ask of these two. + + LAN mode and "Internet only" do not judge WHICH peer - they judge which side + of the address split it is on - so the mirror that proves they are not simply + saying yes is the OPPOSITE class, not another address of the same kind. Both + directions, because that is what this whole file exists for: the inbound + branch reads the remote end from a different header field, and a gate that + got it wrong there would cut traffic nobody asked it to cut. + + Loopback is in here on purpose. It is the one address BOTH switches leave + alone (owner's decision, 2026-08-19), and nothing else in this file would + notice if that carve-out disappeared. + """ + cases = ( + # (name, configure, counter, an address it must NOT touch) + ("LAN mode", lambda e: e.set_lan(True), "drop_lan", LAN_PEER), + ("LAN mode", lambda e: e.set_lan(True), "drop_lan", "127.0.0.1"), + ("Internet only", lambda e: e.set_internet_only(True), + "drop_internet_only", OTHER), + ("Internet only", lambda e: e.set_internet_only(True), + "drop_internet_only", "127.0.0.1"), + ) + for name, configure, counter, bystander in cases: + out = _run(configure, [_outbound_to(bystander)], counter) + check("%s: OUTBOUND to %s is left alone" % (name, bystander), out == 0, + "(%s=%s)" % (counter, out)) + + inn = _run(configure, [_inbound_from(bystander)], counter) + check("%s: INBOUND from %s is left alone" % (name, bystander), inn == 0, + "(%s=%s)" % (counter, inn)) diff --git a/tests/test_repo_conventions.py b/tests/test_repo_conventions.py index bfd1a09..1582205 100644 --- a/tests/test_repo_conventions.py +++ b/tests/test_repo_conventions.py @@ -632,3 +632,46 @@ def test_the_optional_review_never_runs_by_itself(): check("the review job holds no write permission", "write" not in body.split("jobs:", 1)[1].replace("id-token: write", ""), "(a reviewer that can push is not a reviewer)") + + +def test_every_context_menu_is_handed_to_the_dark_theme(): + """A ``tk.Menu`` has to be built INSIDE ``theme.style_menu(...)``. + + ttk styles do not reach a classic menu - on Windows it is a native Win32 + popup, so a menu nobody configures comes up in the system's colours: a white + box in the middle of a dark program. That shipped: the Statistics page's + copy menu was bare while the connection table's was wrapped, and the two sat + two files apart (reported from a running build, 2026-08-19). + + The rule, not the example. A test naming the two menus that exist today + would pass on the day a third one is added bare - which is exactly how this + one arrived. Colours cannot be checked by rendering here anyway: the fake + tkinter records them without drawing, so the wrapper being CALLED is the + strongest mechanical statement available. + """ + import ast + import pathlib + + offenders = [] + for path in sorted(_gui_files()): + source = pathlib.Path(path).read_text(encoding="utf-8") + tree = ast.parse(source, filename=path) + # Every Menu(...) call, and every call sitting directly inside a + # style_menu(...) - the difference is what is missing a wrapper. + menus, styled = [], set() + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + name = node.func.attr if isinstance(node.func, ast.Attribute) else ( + node.func.id if isinstance(node.func, ast.Name) else "") + if name == "Menu": + menus.append(node) + elif name == "style_menu": + styled.update(id(arg) for arg in node.args) + for menu in menus: + if id(menu) not in styled: + offenders.append("%s:%d" % (os.path.basename(path), menu.lineno)) + + check("every tk.Menu is built inside theme.style_menu(...) " + "(ttk styles do not reach it - a bare menu renders WHITE)", + not offenders, f"({offenders})") diff --git a/tests/test_settings_config_scenario.py b/tests/test_settings_config_scenario.py index 19fedf0..1beedf8 100644 --- a/tests/test_settings_config_scenario.py +++ b/tests/test_settings_config_scenario.py @@ -442,3 +442,42 @@ def test_a_config_value_says_what_the_setting_takes(tmp_path): "0" in message and "100" in message, f"({message})") check("config: it quotes back what was actually given", "abc" in message, f"({message})") + + +def test_both_lan_switches_at_once_are_allowed_and_said_out_loud(): + """LAN mode plus "Internet only" is the union of two impairments, so it is a + legal request - and it cuts everything except loopback, which looks far more + like a broken tool than like a tool doing as it was told. + + Refusing it was rejected: a run somebody meant would die on validation. Saying + it once per apply is the same answer the shared-port warning gives, and the + same one the engine gives for a destination frozen by a narrowed filter. + """ + from beantester import DEFAULT_SETTINGS, apply_settings + from beantester.i18n import T + + warning = T("log.lan_and_internet_only") + + def lines_for(**overrides): + said = [] + apply_settings(BeanEngine(), dict(DEFAULT_SETTINGS, **overrides), said.append) + return said + + check("both on: the run says so", + warning in lines_for(lan_mode=True, internet_only=True)) + check("LAN mode alone: nothing to warn about", + warning not in lines_for(lan_mode=True)) + check("Internet only alone: nothing to warn about", + warning not in lines_for(internet_only=True)) + check("neither: nothing to warn about", warning not in lines_for()) + + +def test_internet_only_reaches_the_engine_through_apply_settings(): + """The setting has to arrive at the core, not merely be stored.""" + from beantester import DEFAULT_SETTINGS, apply_settings + engine = BeanEngine() + apply_settings(engine, dict(DEFAULT_SETTINGS, internet_only=True), + lambda *_: None) + check("internet only: armed on the core", engine.core.internet_only is True) + apply_settings(engine, dict(DEFAULT_SETTINGS), lambda *_: None) + check("internet only: disarmed again", engine.core.internet_only is False) diff --git a/tests/test_summary_repro_views.py b/tests/test_summary_repro_views.py index f329e89..b52aa6d 100644 --- a/tests/test_summary_repro_views.py +++ b/tests/test_summary_repro_views.py @@ -339,3 +339,57 @@ def test_settings_to_cli_covers_every_numeric_field(): argv = settings_to_cli({**DEFAULT_SETTINGS, f.key: val}) check(f"repro command includes --{f.cli}", f"--{f.cli}" in argv, f"(field {f.key!r} value {val} produced {argv})") + + +def test_every_setting_with_a_flag_reaches_the_reproduction_command(): + """🔴 ``settings_to_cli`` is a HAND-WRITTEN list, and the registry is not. + + The reproduction command is a public contract: people paste it to re-run + somebody else's session. A field that never made it into this list produces a + command that silently reproduces a DIFFERENT run - no error, no red test, and + the difference only shows up as counters that do not match the report. + + Measured the day this guard was written: THREE flags were missing. Two were + real (``--internet-only``, brand new, and ``--narrow-filter``, which had been + absent for weeks while the repro REPORT carried `narrowed` faithfully) and one + was correct - ``row_limit`` is ``ui_only``, so the engine never sees it and it + cannot change what a re-run does. + + Deriving the list from the registry instead would be the real fix, but the + order of the flags and the "only when it differs from the default" rule are + hand-tuned per field, so the honest guard is this one: enumerate the registry + and demand each flag appear. + """ + from beantester import fields as F + from beantester.matchers import KIND_INT, KIND_IP + from beantester.repro import settings_to_cli + from beantester.settings import DEFAULT_SETTINGS + + def probe_for(field): + if field.kind == F.BOOL: + return True + if field.kind == F.NUMBER: + return 7 + if field.kind == F.SEED: + return 42 + if field.kind == F.SCHEDULE: + return "1:100:0" + if field.kind == F.CHOICE: + return "out" + if field.expr_kind == KIND_INT: + return "4433" + if field.expr_kind == KIND_IP: + return "192.0.2.9" + return "beanprobe.exe" + + missing = [] + for field in F.FIELD_DEFS: + if not field.cli or field.ui_only: + continue + args = settings_to_cli(dict(DEFAULT_SETTINGS, + **{field.key: probe_for(field)})) + if ("--" + field.cli) not in args: + missing.append(field.key) + + check("repro: every engine field with a CLI flag reaches the command", + not missing, f"(missing: {missing})")