diff --git a/CHANGELOG.md b/CHANGELOG.md index ac745d4..5c58f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ### Added +- **Aim at one address family.** Two new switches under the destination target, in the GUI + and on the command line, impair IPv4 only or IPv6 only. The other family keeps flowing + untouched: nothing is blocked and nothing is slowed, it is simply left alone. They work + with the address field empty too, which means all addresses. Turning both on excludes + everything and the log says so. Left alone, the program does what it always did and + covers both families. + - **Simplified Chinese interface translation.** The GUI now ships with a complete `zh` language file alongside English and Polish. A system set to Simplified Chinese selects it automatically. A system set to Traditional Chinese starts in English instead, because the diff --git a/README.md b/README.md index f0f4829..59aab49 100644 --- a/README.md +++ b/README.md @@ -707,6 +707,7 @@ BeanNetworkTester.exe --simulate --duration 30 --format json > run.ndjson | `--rst-prob` `--rst-cooldown` | % / s | percentage of connections torn with RST and how long the tear-down is held | | `--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 | +| `--ipv4-only` `--ipv6-only` | - | impair one address family only. The other keeps flowing untouched - this aims the tool, it does not block a protocol. Works with `--dst-ip` empty too, which means all addresses. Both flags at once exclude each other, nothing is impaired, and the log says so | | `--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 | diff --git a/README.pl.md b/README.pl.md index eedfd06..4efcb35 100644 --- a/README.pl.md +++ b/README.pl.md @@ -558,6 +558,7 @@ BeanNetworkTester.exe --simulate --duration 30 --format json > run.ndjson | `--rst-prob` `--rst-cooldown` | % / s | procent połączeń zrywanych RST-em i czas trzymania zerwanego | | `--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 | +| `--ipv4-only` `--ipv6-only` | - | psuj ruch tylko jednej rodziny adresów. Druga płynie bez zmian - to celowanie, nie blokada protokołu. Działa też przy pustym `--dst-ip`, czyli dla wszystkich adresów. Obie flagi naraz wykluczają się, nic nie zostanie zmienione, a log to mówi | | `--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 | diff --git a/beantester/cli.py b/beantester/cli.py index 8e289e9..3476989 100644 --- a/beantester/cli.py +++ b/beantester/cli.py @@ -93,6 +93,62 @@ def _fail(code, message): """ +def _add_scope_arguments(p): + """The flags that say WHICH traffic is aimed at, rather than what is done to it. + + Split out of ``build_arg_parser`` when the two address-family switches pushed + that function past the size ratchet, and the ratchet said what to do about it: + split, do not raise the ceiling. This is where the seam already was - process, + destination, address family, address class and blocking all answer "which + packets", while everything left behind answers "how are they damaged". + """ + p.add_argument("--target", + help="target processes: name/PID, comma-separated list, range, " + "wildcard, re: pattern, ! to exclude " + "(e.g. 'chrome.exe,!chromedriver' or 're:^fire')") + p.add_argument("--dst-ip", + help="affect only traffic to/from these remote IPs, IPv4 and IPv6: " + "address, list, range a-b, CIDR, wildcard, comparison, re: " + "pattern, ! to exclude (e.g. '10.0.0.1-10.0.0.50,!10.0.0.7')") + p.add_argument("--dst-port", + help="affect only these remote ports: number, list, range a-b, " + "comparison (>1024), wildcard, re: pattern, ! to exclude " + "(e.g. '80,443,8000-8100' or '!53')") + # Part of the targeting, next to --dst-ip: they say WHICH traffic is aimed at, + # and neither blocks anything. The other family keeps flowing untouched. + p.add_argument("--ipv4-only", action="store_true", + help="impair IPv4 traffic only. IPv6 keeps flowing untouched - " + "this aims the tool, it does not block a protocol. Applies " + "with --dst-ip empty too, which means all addresses") + p.add_argument("--ipv6-only", action="store_true", + help="impair IPv6 traffic only. IPv4 keeps flowing untouched. " + "Both flags together exclude each other and nothing is " + "impaired, which the log says out loud") + 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 " + "(much faster at high packet rates). Applied at START only, " + "and then statistics and connections cover the narrowed " + "traffic only - the summary says when it took effect") + p.add_argument("--block-ip", + help="block (drop) all traffic to these remote IPs, IPv4 and IPv6: " + "address, list, range a-b, CIDR, wildcard, re: pattern, " + "! to exclude") + p.add_argument("--block-port", + help="block (drop) all traffic to these remote ports: number, list, " + "range a-b, comparison (>1024), wildcard, re: pattern, ! to exclude " + "(blocks on IP OR port, for example '--block-port 443')") + + def build_arg_parser(): p = argparse.ArgumentParser( prog=program_name(), @@ -131,41 +187,7 @@ def build_arg_parser(): help="link buffer for the speed limit [ms], 0 = unlimited. It " "bounds the queueing delay a rate-limited link builds up " "before it drops (bufferbloat)") - p.add_argument("--target", - help="target processes: name/PID, comma-separated list, range, " - "wildcard, re: pattern, ! to exclude " - "(e.g. 'chrome.exe,!chromedriver' or 're:^fire')") - p.add_argument("--dst-ip", - help="affect only traffic to/from these remote IPs, IPv4 and IPv6: " - "address, list, range a-b, CIDR, wildcard, comparison, re: " - "pattern, ! to exclude (e.g. '10.0.0.1-10.0.0.50,!10.0.0.7')") - p.add_argument("--dst-port", - help="affect only these remote ports: number, list, range a-b, " - "comparison (>1024), wildcard, re: pattern, ! to exclude " - "(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 " - "(much faster at high packet rates). Applied at START only, " - "and then statistics and connections cover the narrowed " - "traffic only - the summary says when it took effect") - p.add_argument("--block-ip", - help="block (drop) all traffic to these remote IPs, IPv4 and IPv6: " - "address, list, range a-b, CIDR, wildcard, re: pattern, " - "! to exclude") - p.add_argument("--block-port", - help="block (drop) all traffic to these remote ports: number, list, " - "range a-b, comparison (>1024), wildcard, re: pattern, ! to exclude " - "(blocks on IP OR port, for example '--block-port 443')") + _add_scope_arguments(p) p.add_argument("--syn-drop", type=float, help="dropped TCP SYN rate [%%]") p.add_argument("--max-size", type=int, help="MTU black hole: drop packets > N B") p.add_argument("--spike-prob", type=float, help="latency spike probability [%%]") diff --git a/beantester/core.py b/beantester/core.py index 88f4ad7..a7439b0 100644 --- a/beantester/core.py +++ b/beantester/core.py @@ -236,6 +236,21 @@ def __init__(self): self.dst_port = "" # raw expression text self.dst_ip_matcher = parse_matcher("", KIND_IP) self.dst_port_matcher = parse_matcher("", KIND_INT) + # Address family, part of the SAME question as the two matchers above: + # which remote ends are in scope. ``None`` means both families - the + # default, and the only value that costs nothing in decide(). + # + # Otherwise it is the SET of families that qualify, holding the answer to + # "is this address IPv6?": {False} for IPv4 only, {True} for IPv6 only, + # and the EMPTY set when the user asked for both at once, where no packet + # can qualify. The empty set is why this is a set and not a flag: the + # contradictory request then needs no case of its own, here or in the gate. + # + # It is deliberately NOT a `narrows` field: restricting to one family + # still reaches every connection of that family on the machine, so + # counting it as a bound would silence the blast-radius warning for a + # session that has bounded nothing. Same reasoning `--target *` gets. + self.family_wanted = None 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 @@ -347,6 +362,29 @@ def set_dest(self, active, ip=None, port=None): self.dst_ip_matcher = ip_matcher self.dst_port_matcher = port_matcher + def set_ip_family(self, ipv4_only=False, ipv6_only=False): + """Restrict the targeting to one address family (default: neither). + + Both flags at once is a legal request meaning "no packet qualifies", the + same way LAN mode plus Internet only means "nothing but loopback gets + through": refused nowhere, and said out loud once by ``apply_settings``. + Refusing it would break a run somebody meant. + """ + ipv4_only, ipv6_only = bool(ipv4_only), bool(ipv6_only) + with self._lock: + if not ipv4_only and not ipv6_only: + self.family_wanted = None # both families: no gate + else: + wanted = set() + if ipv4_only: + wanted.add(False) + if ipv6_only: + wanted.add(True) + # Both asked for: {False, True} would accept everything, which is + # the opposite of what the request means. Two mutually exclusive + # "only" switches leave nothing, so the set is emptied on purpose. + self.family_wanted = frozenset() if len(wanted) == 2 else frozenset(wanted) + def set_lan(self, enabled): with self._lock: self.lan_only = bool(enabled) @@ -355,6 +393,37 @@ def set_internet_only(self, enabled): with self._lock: self.internet_only = bool(enabled) + def _out_of_scope(self, remote_ip, remote_port): + """Is this remote end outside what the session is aiming at? + + Called only when something IS aimed (see the caller's left half), so the + cost of the checks here is paid by the session that asked for them. + + The family test is first because it is the cheapest: an IPv6 address in + text form always carries a colon and an IPv4 one never does, so this is a + substring test on a string the caller already has. ``::ffff:1.2.3.4`` + counts as IPv6, which is what it is on the wire. + + An address this cannot read at all (``None`` - ICMP, an unparsed packet) + is out of scope while a family is chosen, the same direction + ``utils.is_lan_ip`` takes with an address it cannot classify: what cannot + be identified must not be damaged. Without a family chosen it is left to + the destination matchers, exactly as before. + """ + if self.family_wanted is not None and ( + remote_ip is None or (":" in remote_ip) not in self.family_wanted): + return True + # ``dst_active`` is asked again rather than assumed from the caller: a + # family alone brings us here with no destination set, and the matchers + # must stay as ignorable then as they were before this gate existed - + # ``set_dest(False, ...)`` keeps whatever expressions it was handed. + if not self.dst_active: + return False + if self.dst_ip_matcher and not self.dst_ip_matcher.matches(remote_ip): + return True + return bool(self.dst_port_matcher + and not self.dst_port_matcher.matches(remote_port)) + def _address_class_cut(self, remote_ip): """Which address-class switch cuts this packet, or ``None``. @@ -578,12 +647,21 @@ def decide(self, size, is_outbound, local_port, now, rng, if not ((is_syn or not is_tcp) and self._owner_targeted is not None and self._owner_targeted(local_port)): return Decision(False, False, [now], scoped=False) - # 2) destination targeting (remote IP/port) - filter expressions - if self.dst_active: - if self.dst_ip_matcher and not self.dst_ip_matcher.matches(remote_ip): - return Decision(False, False, [now], scoped=False) - if self.dst_port_matcher and not self.dst_port_matcher.matches(remote_port): - return Decision(False, False, [now], scoped=False) + # 2) targeting: the destination expressions and the address family. + # + # One gate and one branch for all three, and that is not tidiness: + # this function sits ON the complexity ceiling pinned in + # pyproject.toml, where the rule is that splitting lowers the number + # rather than raising it. The three tests answer the same question + # ("is this remote end in scope?") and returned the identical verdict + # already, so the fold changes nothing a packet can tell apart. + # + # The left half keeps the common case cheap, exactly like step 2b: two + # attribute reads with no targeting armed, and the call happens only in + # a session that asked for it. + if (self.dst_active or self.family_wanted is not None) and self._out_of_scope( + remote_ip, remote_port): + return Decision(False, False, [now], scoped=False) # 2b) the two address-class switches: LAN mode (cut the internet) and # "Internet only" (cut the local network). Both are asked through one diff --git a/beantester/engine.py b/beantester/engine.py index fa7647f..7d7a33a 100644 --- a/beantester/engine.py +++ b/beantester/engine.py @@ -486,6 +486,9 @@ def process_target_active(self): """ return self.core.process_target_active() + def set_ip_family(self, *a, **kw): + self.core.set_ip_family(*a, **kw) + def set_lan(self, *a): self.core.set_lan(*a) diff --git a/beantester/fields.py b/beantester/fields.py index a0bd90f..277149e 100644 --- a/beantester/fields.py +++ b/beantester/fields.py @@ -195,6 +195,25 @@ class Field(NamedTuple): Field("dst_port", EXPR, "fields.port", "destination", expr_kind=KIND_INT, bounds=PORT_BOUNDS, width=18, tip="tips.dest", span=True, cli="dst-port", narrows=True), + # Which address family the targeting covers - part of the same question as + # the IP field above it, which is why it lives in this card and not next to + # "LAN mode" and "Internet only". Those two CUT traffic (impairs=all); these + # two only say what is aimed at, and a reader who takes the wrong one of those + # meanings ends up believing the tool blocks a whole protocol. + # + # 🔴 NOT narrows=True, and that is a safety decision rather than an omission. + # "IPv4 only" still reaches every IPv4 connection on the machine, so counting + # it as a bound would silence the blast-radius warning for a session that has + # bounded nothing - the same hole `--target *` was fixed for on 2026-08-06. + # + # span=False on both: one decision seen from two sides, so they share a row + # (a BOOL takes a whole row by kind - see Field.span), and the section carries + # columns=2 for them. Both at once is legal and means "nothing qualifies"; + # settings.apply_settings says so once, like the LAN/Internet pair. + Field("ipv4_only", BOOL, "fields.ipv4_only", "destination", + tip="tips.ipv4_only", span=False, cli="ipv4-only"), + Field("ipv6_only", BOOL, "fields.ipv6_only", "destination", + tip="tips.ipv6_only", span=False, cli="ipv6-only"), # -- blocking (firewall) ---------------------------------------------- # # Drop traffic to matching destinations outright. IP OR port (each takes part @@ -323,7 +342,10 @@ class Section(NamedTuple): ("latency", "jitter", "spike_prob", "spike_ms"), columns=2), Section("impairments", "frames.impairments", ("loss", "corrupt", "dup"), columns=3), Section("flapping", "frames.flapping", ("flap_period", "flap_down"), columns=2), - Section("destination", "frames.destination", ("dst_ip", "dst_port"), columns=1), + # columns=2 for the family pair only: both expression fields above carry + # span=True and keep a row each regardless, so this changes nothing they do. + Section("destination", "frames.destination", + ("dst_ip", "dst_port", "ipv4_only", "ipv6_only"), columns=2), Section("block", "frames.block", ("block_ip", "block_port"), columns=1), Section("advanced", "frames.advanced", ("syn_drop", "max_size", "nat_timeout", "rst_prob", "rst_cooldown"), diff --git a/beantester/repro.py b/beantester/repro.py index d720f58..331edb5 100644 --- a/beantester/repro.py +++ b/beantester/repro.py @@ -43,6 +43,10 @@ def settings_to_cli(settings, seed=None, simulate=False): args += ["--block-port", block_port] if g("lan_mode"): args += ["--lan-mode"] + if g("ipv4_only"): + args += ["--ipv4-only"] + if g("ipv6_only"): + args += ["--ipv6-only"] if g("internet_only"): args += ["--internet-only"] # START-only, and it changes what the session even SAW - a command without it diff --git a/beantester/settings.py b/beantester/settings.py index a31ea84..cc1484f 100644 --- a/beantester/settings.py +++ b/beantester/settings.py @@ -23,6 +23,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, + ipv4_only=False, ipv6_only=False, internet_only=False, # the mirror of lan_mode; loopback survives both block_ip="", block_port="", # firewall: drop traffic to matching IP/port @@ -531,6 +532,13 @@ def apply_settings(engine, s, log=lambda *_: None): # validate up front (validate_settings), so a user never reaches this. log(f"{T('log.filter_skipped')}: {e}") engine.set_dest(False) + engine.set_ip_family(bool(g("ipv4_only")), bool(g("ipv6_only"))) + # The same shape as the pair below, and said for the same reason: two "only" + # switches that exclude each other leave nothing to aim at, the symptom is a + # session that changes nothing, and that looks like a broken tool rather than + # like the tool doing what it was told. + if g("ipv4_only") and g("ipv6_only"): + log(T("log.ipv4_and_ipv6_only")) 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 diff --git a/beantester/summary.py b/beantester/summary.py index da50141..24d1c90 100644 --- a/beantester/summary.py +++ b/beantester/summary.py @@ -55,10 +55,20 @@ def settings_summary(s, lang=None, prefix_key="summary.prefix"): parts.append(tr("summary.rst", v=num("rst_prob"))) if to_number(g("flap_period")) and to_number(g("flap_down")): 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")) + # The plain on/off switches, as a table rather than four identical branches. + # Each one adds a fixed phrase when it is on and nothing when it is off, so + # there was never anything to tell them apart except the two names - and the + # size and complexity ratchets both count the branches, which is how a fifth + # switch would have started costing something it should not. + # + # Order is the reading order of the form: which traffic is aimed at + # (the family pair), then which address classes are cut. + for key, phrase in (("ipv4_only", "summary.ipv4_only"), + ("ipv6_only", "summary.ipv6_only"), + ("lan_mode", "summary.lan"), + ("internet_only", "summary.internet_only")): + if g(key): + parts.append(tr(phrase)) if scheduled: parts.append(tr("summary.schedule")) if str(g("target")).strip(): diff --git a/lang/en.json b/lang/en.json index 57785c7..682e65b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -186,6 +186,8 @@ "fields.flap_down_pct": "Downtime percent:", "fields.internet_only": "Internet only (no local network)", "fields.ip": "IP:", + "fields.ipv4_only": "IPv4 addresses only", + "fields.ipv6_only": "IPv6 addresses only", "fields.jitter": "Jitter:", "fields.lan_mode": "LAN mode (local network only, no internet)", "fields.latency": "Latency:", @@ -273,6 +275,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.ipv4_and_ipv6_only": "IPv4 addresses only and IPv6 addresses only are both on. No packet is both, so nothing will be impaired.", "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", @@ -421,6 +424,8 @@ "summary.dup": "{v}% duplicates", "summary.flap": "outages every {v} s", "summary.internet_only": "internet only (no local network)", + "summary.ipv4_only": "IPv4 only", + "summary.ipv6_only": "IPv6 only", "summary.jitter": "jitter +/-{v} ms", "summary.lan": "LAN mode (no internet)", "summary.latency": "+{v} ms ping", @@ -499,6 +504,8 @@ "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.ipv4_only": "Impairments touch IPv4 traffic only. IPv6 keeps flowing normally: it is not blocked and not slowed, just left alone. This applies with the IP field empty too, which means all addresses.", + "tips.ipv6_only": "Impairments touch IPv6 traffic only. IPv4 keeps flowing normally: it is not blocked and not slowed, just left alone. This applies with the IP field empty too, which means all addresses.", "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.", diff --git a/lang/pl.json b/lang/pl.json index f72c7af..fa40f81 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -186,6 +186,8 @@ "fields.flap_down_pct": "Procent przerwy w łączu:", "fields.internet_only": "Tylko internet (bez sieci lokalnej)", "fields.ip": "IP:", + "fields.ipv4_only": "Tylko adresy IPv4", + "fields.ipv6_only": "Tylko adresy IPv6", "fields.jitter": "Jitter:", "fields.lan_mode": "Tryb LAN (tylko sieć lokalna, bez internetu)", "fields.latency": "Latencja:", @@ -273,6 +275,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.ipv4_and_ipv6_only": "Włączone są naraz Tylko adresy IPv4 i Tylko adresy IPv6. Żaden pakiet nie jest jednym i drugim, więc nic nie zostanie zmienione.", "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", @@ -421,6 +424,8 @@ "summary.dup": "{v}% duplikatów", "summary.flap": "przerwy co {v} s", "summary.internet_only": "tylko internet (bez sieci lokalnej)", + "summary.ipv4_only": "tylko IPv4", + "summary.ipv6_only": "tylko IPv6", "summary.jitter": "jitter +/-{v} ms", "summary.lan": "tryb LAN (bez internetu)", "summary.latency": "+{v} ms pingu", @@ -499,6 +504,8 @@ "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.ipv4_only": "Zakłócenia dotykają tylko ruchu po IPv4. Ruch IPv6 płynie normalnie: nie jest blokowany ani spowalniany, po prostu zostaje bez zmian. Działa też przy pustym polu IP, czyli dla wszystkich adresów.", + "tips.ipv6_only": "Zakłócenia dotykają tylko ruchu po IPv6. Ruch IPv4 płynie normalnie: nie jest blokowany ani spowalniany, po prostu zostaje bez zmian. Działa też przy pustym polu IP, czyli dla wszystkich adresów.", "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.", diff --git a/lang/zh.json b/lang/zh.json index e8d6800..7684f3a 100644 --- a/lang/zh.json +++ b/lang/zh.json @@ -186,6 +186,8 @@ "fields.flap_down_pct": "中断占比:", "fields.internet_only": "仅互联网(禁用本地网络)", "fields.ip": "IP:", + "fields.ipv4_only": "仅 IPv4 地址", + "fields.ipv6_only": "仅 IPv6 地址", "fields.jitter": "抖动:", "fields.lan_mode": "局域网模式(仅本地网络,不访问互联网)", "fields.latency": "延迟:", @@ -273,6 +275,7 @@ "log.engine_fault": "引擎故障:{e}。会话已停止,网络已恢复正常。", "log.error": "错误", "log.filter_skipped": "无法解析此表达式,本次会话已将其关闭", + "log.ipv4_and_ipv6_only": "同时启用了“仅 IPv4 地址”和“仅 IPv6 地址”。没有数据包同时属于两者,因此不会有任何改动。", "log.lan_and_internet_only": "“局域网模式”和“仅互联网”同时启用,因此除环回流量外,其他流量都无法通过。", "log.layout_reset": "窗口布局已重置。", "log.loaded_profile": "已加载配置方案", @@ -421,6 +424,8 @@ "summary.dup": "{v}% 重复包", "summary.flap": "每 {v} 秒发生中断", "summary.internet_only": "仅互联网(无本地网络)", + "summary.ipv4_only": "仅 IPv4", + "summary.ipv6_only": "仅 IPv6", "summary.jitter": "抖动 ±{v} ms", "summary.lan": "局域网模式(无互联网)", "summary.latency": "延迟 +{v} ms", @@ -499,6 +504,8 @@ "tips.flap": "周期性完全断线:每隔“周期”秒,链路会按设定的时间比例处于中断状态。可模拟反复掉线的连接,例如信号很弱的 Wi‑Fi。", "tips.freeze": "冻结表格,使各行暂时不再移动,方便检查或复制。", "tips.internet_only": "切断本地网络但保留互联网:与本地地址(10.x、192.168.x、172.16-31.x、链路本地地址、CGNAT)往返的流量会被丢弃。环回地址(127.x)仍可使用,因此本机程序之间的内部通信不受影响。注意:若电脑向路由器请求 DNS,互联网也会失效,因为这些 DNS 查询属于本地流量。", + "tips.ipv4_only": "只对 IPv4 流量施加弱网效果。IPv6 流量照常通过,既不会被阻断也不会被限速,只是保持原样。即使 IP 字段为空(即所有地址)也同样生效。", + "tips.ipv6_only": "只对 IPv6 流量施加弱网效果。IPv4 流量照常通过,既不会被阻断也不会被限速,只是保持原样。即使 IP 字段为空(即所有地址)也同样生效。", "tips.jitter": "为每个数据包分别随机增加或减少指定毫秒数的延迟,使 Ping 不再稳定并产生轻微乱序。请求和响应会各自独立随机,因此 Ping 的实际波动通常约为此数值的 1.4 倍,极端情况下可达到 2 倍。", "tips.lan_mode": "模拟无法访问互联网但局域网仍可用的环境:与公共地址往返的流量会被丢弃,而局域网地址(10.x、192.168.x、172.16-31.x、环回地址)仍可通信。可用于测试应用在断网但内网正常时的表现。", "tips.language": "界面语言。切换后会重建界面,但当前会话和设置都会保留。会话运行期间无法切换。", diff --git a/pyproject.toml b/pyproject.toml index 2e6c4b3..6ae1115 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,9 +57,10 @@ addopts = "-q" # PLR0913 pylint - how many arguments a function takes # # 🔴 Why PLR0913 ALONE out of the pylint refactor rules, and why not the obvious -# three next to it. PLR0912 (branches) scores `core.decide` at 30 where C90 scores -# it 29 - the same function, the same axis, a second number that moves at a -# different rate. PLR0915 (statements) is FUNCTION_CEILING measured worse, because +# three next to it. PLR0912 (branches) scores `core.decide` at 28 where C90 scores +# it 27 - the same function, the same axis, a second number that moves at a +# different rate. (Both were two higher until the targeting tests moved into +# `core._out_of_scope`; re-measured then rather than left to drift.) PLR0915 (statements) is FUNCTION_CEILING measured worse, because # the ratchet in tests/test_code_shape.py counts LOGIC lines and therefore leaves # comments free, which this repository is built on. PLR0911 (returns) needs a # branch per return, so C90 has already counted them. Argument count is the one @@ -97,9 +98,12 @@ max-args = 14 [tool.ruff.lint.mccabe] # 🔴 THE CEILING IS THE MEASUREMENT, like FILE_CEILING in tests/test_code_shape.py. -# 29 is `core.decide` today - the twelve-step packet pipeline, which is branchy -# because the thing it describes is - and 27 is `cli._run_session` behind it. -# Nothing else in the package passes 24. +# 27 is `cli._run_session` and `core.decide` today, level. It was 29, which was +# `decide` alone: adding the address-family gate would have been a thirtieth way +# through that function, so its three targeting tests - which asked one question +# and returned one verdict - moved into `core._out_of_scope` instead, and the +# number came down with them. That is the rule below applied rather than quoted. +# Nothing else in the package passes 25 (`summary.settings_summary`). # # The size ratchet already watches how LONG a function is. This watches how many # ways through it there are, which is the number that decides whether a person @@ -109,7 +113,7 @@ max-args = 14 # # Down is routine, up is a decision. Splitting something means lowering this in # the same change. -max-complexity = 29 +max-complexity = 27 [tool.ruff.lint.per-file-ignores] # A test suite for a packet mangler asserts, randomises, spawns subprocesses and diff --git a/tests/test_cli_runtime.py b/tests/test_cli_runtime.py index b830bbe..b1f477a 100644 --- a/tests/test_cli_runtime.py +++ b/tests/test_cli_runtime.py @@ -214,6 +214,7 @@ def set_seed(self, *_a, **_k): pass def set_params(self, *_a, **_k): pass def set_buffer(self, *_a, **_k): pass def set_dest(self, *_a, **_k): pass + def set_ip_family(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 @@ -374,6 +375,7 @@ def set_seed(self, *_a, **_k): pass def set_params(self, *_a, **_k): pass def set_buffer(self, *_a, **_k): pass def set_dest(self, *_a, **_k): pass + def set_ip_family(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 diff --git a/tests/test_code_shape.py b/tests/test_code_shape.py index ac58862..30f9d47 100644 --- a/tests/test_code_shape.py +++ b/tests/test_code_shape.py @@ -493,7 +493,20 @@ def test_the_ceilings_are_not_set_so_loosely_that_they_never_fire(): # counts, which walk the package only. That is deliberate: the ceiling this band # hangs from is repo-wide, so a band scoped to the package would be measuring a # different thing from the number it is a percentage of. -COMPLEX_NEAR_CEILING = 3 # decide, _run_session, settings_summary +# 🔴 5 since 2026-08-31, and the reason matters more than the number: NOTHING grew +# into this band. The band came down to two functions that were already there. +# `core.decide` was split for the address-family gate, which lowered the ceiling +# from 29 to 27 (the rule beside max-complexity), and 70% of 27 is 18 where 70% of +# 29 was 20 - so `engine._capture_loop` at 20 and `test_layering._module_level` at +# 19 are in the band without either of them changing by a line. +# +# That is worth writing down because the count alone reads like a regression and +# is the opposite: `decide` went 29 -> 27 and `summary.settings_summary` came back +# to 25 from the 27 the same change had pushed it to. Lowering a ceiling tightens +# the band that hangs off it, and this number has to be re-measured when it moves, +# exactly like the ceiling itself. +COMPLEX_NEAR_CEILING = 5 # decide, _run_session, settings_summary, + # _capture_loop, test_layering._module_level # Ruff is not in requirements-dev.txt: it lives in requirements-lint.txt, which a diff --git a/tests/test_core.py b/tests/test_core.py index a29f5ca..fb2eb1d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -133,6 +133,114 @@ def test_dest_port_targeting(): check("dest port: port 443 impaired", p443.drop is True) +def _family_verdicts(core): + """(IPv4 verdict, IPv6 verdict) for the same 100%-loss settings.""" + rng = random.Random(1) + v4 = core.decide(100, True, 5000, 0.0, rng, remote_ip="1.2.3.4", remote_port=80) + v6 = core.decide(100, True, 5000, 0.0, rng, remote_ip="2001:db8::1", remote_port=80) + return v4, v6 + + +def test_address_family_targeting(): + """One family in scope leaves the other ALONE - not blocked, not slowed. + + That distinction is the whole point of the switch and the thing its label has + to survive being misread about: an out-of-family packet takes the same exit as + a packet outside the destination target, which is "pass it through untouched". + """ + core = BeanCore() + core.set_params(100, 0, 0, 0, 0, 0, 0) # 100% loss for anything in scope + v4, v6 = _family_verdicts(core) + check("default: both families are impaired", v4.drop is True and v6.drop is True) + + core.set_ip_family(ipv4_only=True) + v4, v6 = _family_verdicts(core) + check("IPv4 only: IPv4 impaired", v4.drop is True) + check("IPv4 only: IPv6 passes untouched and out of scope", + v6.drop is False and v6.scoped is False and v6.releases == [0.0]) + + core.set_ip_family(ipv6_only=True) + v4, v6 = _family_verdicts(core) + check("IPv6 only: IPv6 impaired", v6.drop is True) + check("IPv6 only: IPv4 passes untouched", v4.drop is False and v4.scoped is False) + + core.set_ip_family() # back to the default + v4, v6 = _family_verdicts(core) + check("cleared: both families impaired again", + v4.drop is True and v6.drop is True) + + +def test_both_families_only_means_nothing_qualifies(): + """Two mutually exclusive "only" switches leave an empty set, and the engine + says so by impairing nothing - the same shape as LAN mode plus Internet only. + The warning that this is probably a mistake is `apply_settings`' job, not + this one: refusing it here would break a run somebody meant.""" + core = BeanCore() + core.set_params(100, 0, 0, 0, 0, 0, 0) + core.set_ip_family(ipv4_only=True, ipv6_only=True) + v4, v6 = _family_verdicts(core) + check("both 'only' switches: nothing is impaired", + v4.drop is False and v6.drop is False) + check("and nothing is in scope either", + v4.scoped is False and v6.scoped is False) + + +def test_an_address_the_family_gate_cannot_read_is_left_alone(): + """A packet with no remote address (ICMP, an unparsed frame) has no family. + + It goes out of scope while a family is chosen, which is the direction + `utils.is_lan_ip` already takes with an address it cannot classify: what + cannot be identified must not be damaged. With no family chosen it is left to + the destination matchers, exactly as before the gate existed. + """ + core = BeanCore() + core.set_params(100, 0, 0, 0, 0, 0, 0) + rng = random.Random(1) + check("no family chosen: an address-less packet is still impaired", + core.decide(100, True, 5000, 0.0, rng, + remote_ip=None, remote_port=None).drop is True) + core.set_ip_family(ipv4_only=True) + check("family chosen: an address-less packet is left alone", + core.decide(100, True, 5000, 0.0, rng, + remote_ip=None, remote_port=None).drop is False) + + +def test_an_ipv4_mapped_address_counts_as_the_family_it_is_on_the_wire(): + """`::ffff:1.2.3.4` is an IPv6 packet carrying an IPv4 address, and the gate + reads the packet rather than the intent behind the notation.""" + core = BeanCore() + core.set_params(100, 0, 0, 0, 0, 0, 0) + rng = random.Random(1) + core.set_ip_family(ipv6_only=True) + check("IPv6 only: a mapped address is IPv6", + core.decide(100, True, 5000, 0.0, rng, + remote_ip="::ffff:1.2.3.4", remote_port=80).drop is True) + core.set_ip_family(ipv4_only=True) + check("IPv4 only: a mapped address is not IPv4", + core.decide(100, True, 5000, 0.0, rng, + remote_ip="::ffff:1.2.3.4", remote_port=80).drop is False) + + +def test_the_family_gate_and_the_destination_target_both_have_to_pass(): + """Two halves of one question, and the fold that put them in one branch may + not have turned an AND into an OR.""" + core = BeanCore() + core.set_params(100, 0, 0, 0, 0, 0, 0) + core.set_dest(True, ip="1.2.3.4") + core.set_ip_family(ipv4_only=True) + rng = random.Random(1) + check("right family, right address: impaired", + core.decide(100, True, 5000, 0.0, rng, + remote_ip="1.2.3.4", remote_port=80).drop is True) + check("right family, wrong address: passed", + core.decide(100, True, 5000, 0.0, rng, + remote_ip="9.9.9.9", remote_port=80).drop is False) + core.set_ip_family(ipv6_only=True) + check("wrong family, address the target would accept: passed", + core.decide(100, True, 5000, 0.0, rng, + remote_ip="1.2.3.4", remote_port=80).drop is False) + + def test_syn_drop(): core = BeanCore() core.set_advanced(100, 0) # 100% dropped SYN diff --git a/tests/test_ip_family_default.py b/tests/test_ip_family_default.py new file mode 100644 index 0000000..c0d22f8 --- /dev/null +++ b/tests/test_ip_family_default.py @@ -0,0 +1,87 @@ +"""Both address families are the default, and adding a way to choose one may not change that. + +WHY THIS FILE EXISTS AT ALL + Capturing IPv6 was not free here. The filters all began with ``ip and ...``, + which in the WinDivert language means IPv4 ONLY, so every IPv6 packet went + past the tool - not impaired, not counted, not listed - which on a dual-stack + machine is most of a browser's traffic. ``filters.py`` was rewritten around + that, and its module docstring still leads with the rule. + + The address-family switch is a way to ask for exactly the state that bug + produced. That makes the DEFAULT worth a guard of its own rather than a + shrug: everything here asserts what an untouched form does, and nothing here + asserts what the new switch does (``test_core.py`` covers that). + + The filter strings are written out as literals rather than computed from the + thing under test. A guard that builds its expectation the same way the code + does agrees with the code by construction, including when the code is wrong - + which is precisely the failure this file is here to catch. +""" +import random + +from beantester.core import BeanCore +from beantester.filters import FILTER_DEFS, windivert_for +from beantester.repro import settings_to_cli +from beantester.settings import DEFAULT_SETTINGS, settings_from_raw +from beantester.summary import settings_summary +from fakes import check + +# Every driver filter this program can ask for, spelled out. Both families in +# every one of them: `ip` is IPv4, `ipv6` is IPv6, and `ping` covers both through +# its two protocol names instead. +EXPECTED_FILTERS = { + "both": "(ip or ipv6) and (tcp or udp or icmp or icmpv6)", + "out": "outbound and (ip or ipv6) and (tcp or udp or icmp or icmpv6)", + "in": "inbound and (ip or ipv6) and (tcp or udp or icmp or icmpv6)", + "tcp": "(ip or ipv6) and tcp", + "udp": "(ip or ipv6) and udp", + "ping": "icmp or icmpv6", + "loopback": "loopback and (ip or ipv6) and (tcp or udp or icmp or icmpv6)", +} + + +def _settings(**overrides): + result = settings_from_raw(dict(overrides)) + return result[0] if isinstance(result, tuple) else result + + +def test_every_driver_filter_still_carries_both_families(): + """The one that would catch a family folded into the filter by accident.""" + check("the registry gained or lost no filter", + sorted(key for key, _, _ in FILTER_DEFS) == sorted(EXPECTED_FILTERS), + f"({sorted(key for key, _, _ in FILTER_DEFS)})") + for key, expected in EXPECTED_FILTERS.items(): + check(f"filter {key!r} is unchanged, character for character", + windivert_for(key) == expected, f"({windivert_for(key)!r})") + + +def test_the_default_settings_choose_neither_family(): + check("ipv4_only is off by default", DEFAULT_SETTINGS["ipv4_only"] is False) + check("ipv6_only is off by default", DEFAULT_SETTINGS["ipv6_only"] is False) + + +def test_a_default_session_impairs_both_families(): + """The behaviour the switch exists to narrow, asserted from the other side.""" + core = BeanCore() + core.set_params(100, 0, 0, 0, 0, 0, 0) + rng = random.Random(1) + v4 = core.decide(100, True, 5000, 0.0, rng, remote_ip="1.2.3.4", remote_port=80) + v6 = core.decide(100, True, 5000, 0.0, rng, remote_ip="2001:db8::1", remote_port=80) + check("IPv4 is impaired by default", v4.drop is True and v4.scoped is True) + check("IPv6 is impaired by default", v6.drop is True and v6.scoped is True) + + +def test_the_default_summary_and_repro_command_gained_nothing(): + """A field that says nothing at its default keeps the two things a user reads + exactly as they were - the sentence under the title and the command they copy.""" + settings = _settings(loss=10) + summary = settings_summary(settings, lang="en") + check("no family fragment in the summary of a default form", + "IPv4" not in summary and "IPv6" not in summary, f"({summary!r})") + args = settings_to_cli(settings) + check("no family flag in the reproduction command", + "--ipv4-only" not in args and "--ipv6-only" not in args, f"({args})") + # And the other direction, so this cannot pass by the flags never existing. + armed = settings_to_cli(_settings(loss=10, ipv4_only=True)) + check("the flag does appear once it is asked for", "--ipv4-only" in armed, + f"({armed})") diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index 9e19500..490a8be 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -368,8 +368,12 @@ # function and cannot see the runners-up climbing together underneath it. "label": "ratchet: the complexity crowd count is frozen looser than the measurement", "file": "tests/test_code_shape.py", - "old": "COMPLEX_NEAR_CEILING = 3 # decide, _run_session, settings_summary", - "new": "COMPLEX_NEAR_CEILING = 5 # decide, _run_session, settings_summary", + # Re-anchored 2026-08-31: the constant moved 3 -> 5 when the complexity + # ceiling came down and the band came down with it. The mutation still + # proves the same thing - a count frozen looser than today's measurement + # is caught by the equality half of that test, not by the "at most" half. + "old": "COMPLEX_NEAR_CEILING = 5 # decide, _run_session, settings_summary,", + "new": "COMPLEX_NEAR_CEILING = 7 # decide, _run_session, settings_summary,", "test": "test_nothing_else_is_creeping_up_on_the_complexity_ceiling", }, {