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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions README.pl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
92 changes: 57 additions & 35 deletions beantester/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 [%%]")
Expand Down
90 changes: 84 additions & 6 deletions beantester/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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``.

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions beantester/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
24 changes: 23 additions & 1 deletion beantester/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down
Loading
Loading