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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ HISTORY_NOTES.md
# every added test. It stays next to the notes it argues with, not in the
# release. Its structure is guarded by .claude/hooks/check_notes.py.
CHANGELOG-INTERNAL.md
# Handoff briefs for a next session: ours, and they name our own mistakes.
HANDOFF-*.md

# Internal scripts kept between sessions: measurement rigs, probes, diagnostics.
# Never shipped, never a CI dependency, backed up by the owner separately.
Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol

### Added

- **A run that would break everything now says so first.** Start with impairment on, nothing to
aim it at and no time limit, and both the command line and the window say: this affects every
connection on this machine, set a target or a time limit to narrow it. A warning, not a
refusal. Silent in `--simulate`, and once the run is aimed or timed. The same line appears when
a running session becomes unbounded, and in `--dry-run`. "LAN mode" counts as impairment here:
on its own it cuts every connection that leaves the local network.
- **Ctrl+F, and the row menu from the keyboard.** Ctrl+F brings Connections forward and puts the
caret in its search box, and does the same inside the event-log window. Shift+F10, or the menu
key, opens the row menu on the selected connection. Nothing in the table needed a mouse before.
- **An empty table says why it is empty.** "0 of N" under the table was the only sign that a
search had simply matched nothing, which reads the same as something being broken. It tells the
two cases apart: nothing captured yet, or nothing matching what you typed.
- **`--help` starts with worked examples.** It used to open with 24 lines listing every flag,
before a single readable sentence. Four examples now come first - a safe trial run, one aimed
at an application, one aimed at a destination, and one for a pipeline - and the flag list
follows. A mistyped flag shows the error instead of burying it under the same wall.
- **Numbers in the tables line up on the right.** Packets, bytes, ports, PIDs and times were
anchored left, so 9 and 1000000 began at the same pixel and a column of numbers could not be
scanned down. Addresses stay on the left where they read properly, and the short columns beside
a number - protocol, "impaired?", the timestamp - sit centred so the two never touch. Applies to
Connections and to both views of the event log.
- **An impaired row no longer relies on colour alone.** It is shown in bold as well as in orange,
so it stays recognisable whichever columns you have chosen to show - including with the
"impaired?" column hidden.
- **Connections: the search box can search one column at a time.** Plain text works as before,
and now a term can name its column: `port:443`, `ip:10.0.0.0/8`, `pid:>4000`, `scoped:yes`,
`dropped:>0`. Values use the same notation as the Control page fields, and several terms narrow
Expand All @@ -24,6 +48,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol

### Docs

- **`--help` reads like ordinary writing.** Six flags described themselves with a semicolon
joining two halves of a sentence: `--preset`, `--filter`, `--buffer`, `--dst-ip`, `--block-ip`
and `--block-port`. They now use a full stop or a comma, like the rest of the program's text.
- Both READMEs now say in the licence section that **what you make with the tool is yours**.
Scenarios you write, saved profiles and config files, reproduction reports, CSV exports, logs
and screenshots are your own work: the GPL covers the program, not its output, and using the
Expand All @@ -32,6 +59,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol

### Fixed

- **Error messages say what to do instead of who is at fault.** A config file with an unusable
number answered "Invalid value for 'loss'". It now says the setting needs a number between 0
and 100, and quotes back what it got. A misspelled setting in a scenario file gets the same
"did you mean" suggestion config files have always had, an unknown scenario action lists the
ones that exist, and saving a profile names the field instead of "Values must be numbers".
- **A skipped expression no longer names the wrong feature.** The line about an expression that
could not be read claimed targeting was switched off, even when the expression was a blocking
rule.
- **A broken scenario file no longer starts the session first.** `--scenario` with a file the tool
cannot read used to open the capture, impair traffic and only then report the problem. The file
is now read before anything starts, exactly as `--dry-run` already checked it. Same exit code as
before.
- In the Settings window, "Capture only the targeted traffic" stayed clickable after you pressed
START, if the window was already open at the time. Ticking it did nothing until the next
session. It now greys out for as long as the session runs, with the same "locked while running"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ current screen.
row per address with the port cells left empty.
- **"down"/"up"/"total" are what the application actually got**, the same quantity the session
panel calls "Downloaded (MB)". **"down seen"/"up seen" are what the tool captured** before
impairing anything. With nothing set they are equal; the moment you add loss or a speed limit
impairing anything. With nothing set they are equal. The moment you add loss or a speed limit
they part, and **the gap between them is the damage on that connection**. Hover any of them for
the full sentence. (Before this split there was one pair, holding the captured bytes under
headings that meant delivered: a row could read 5 MB received while its application got 0.4 MB.) Plus a search box (debounced, so it does not churn the table on every keystroke),
Expand Down
101 changes: 85 additions & 16 deletions beantester/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from .scenario import load_scenario_file
from .settings import (DEFAULT_SETTINGS, apply_settings, build_matchers,
load_config_file, parse_schedule, save_config_file,
validate_ranges)
validate_ranges, warn_if_unbounded)
from .synthetic import SyntheticDivert
from .utils import bytes_to_mb

Expand Down Expand Up @@ -59,12 +59,49 @@ def _fail(code, message):
raise CliError(code, message)


# Examples BEFORE the flag list, because that is the order a reader needs them in
# (clig.dev). The usage block alone runs to 24 lines of about fifty flags, and the
# first thing anyone wants from a tool that size is one line they can copy. Four,
# chosen to cover the four shapes a run comes in: harmless, aimed, timed and
# machine-readable - the last two being what a pipeline needs.
#
# Kept in the description rather than the epilog: the epilog holds the exit-code
# table, which is the reference half, and argparse prints the description ABOVE
# the flags and the epilog below them.
_DESCRIPTION = """Bean Network Tester - poor network conditions simulator.
Without arguments it launches the GUI.

Examples:
%(prog)s --simulate --loss 20 --duration 10
try it out. No driver, no real traffic, nothing to break.

%(prog)s --target chrome.exe --latency 200 --duration 30
impair one application for half a minute, and nothing else.

%(prog)s --dst-ip 10.0.0.5 --dst-port 443 --loss 5 --duration 60
impair one destination. Narrow beats broad: the rest of the
machine, including this shell, keeps working.

%(prog)s --preset 3g --duration 60 --format json --repro-out run.json
a named link profile, machine-readable output, and a report that
carries the command needed to repeat the run.

A run with impairment turned on, no target and no --duration affects every
connection on this machine until you stop it, and says so before it starts.
"""


def build_arg_parser():
p = argparse.ArgumentParser(
prog=program_name(),
# One line, not the 24 argparse generates from about fifty flags. Those
# 24 lines sat above every readable thing in --help, and above the message
# on a typo too - so the one sentence saying what was wrong arrived at the
# bottom of a wall nobody reads. The flags are listed in full immediately
# below, which is where a list belongs (clig.dev).
usage="%(prog)s [options]",
formatter_class=argparse.RawDescriptionHelpFormatter,
description="Bean Network Tester - poor network conditions simulator. "
"Without arguments it launches the GUI.",
description=_DESCRIPTION,
epilog=exitcodes.HELP_TABLE)
p.add_argument("--version", action="version",
version=f"{APP_NAME} {__version__}")
Expand All @@ -76,10 +113,10 @@ def build_arg_parser():
p.add_argument("--config", help="load settings from a JSON file")
p.add_argument("--save-config", help="save effective settings to a JSON file and exit")
p.add_argument("--preset", metavar="PRESET",
help="load a preset (canonical id or its name in any UI language; "
"see README for the list)")
help="load a preset by canonical id or by its name in any UI "
"language (the README lists them)")
p.add_argument("--filter", choices=list(CLI_FILTERS), default=None,
help="which traffic to capture at all (IPv4 and IPv6); ports are "
help="which traffic to capture at all (IPv4 and IPv6). Ports are "
"filtered with --dst-port, not here")
p.add_argument("--loss", type=float, help="packet loss [%%]")
p.add_argument("--corrupt", type=float, help="corruption [%%]")
Expand All @@ -89,17 +126,17 @@ def build_arg_parser():
p.add_argument("--down", type=float, help="download limit [KB/s]")
p.add_argument("--up", type=float, help="upload limit [KB/s]")
p.add_argument("--buffer", type=float,
help="link buffer for the speed limit [ms], 0 = unlimited; "
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: 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'); IPv4 and IPv6")
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 "
Expand All @@ -113,12 +150,13 @@ def build_arg_parser():
"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: address, list, "
"range a-b, CIDR, wildcard, re: pattern, ! to exclude; IPv4 and IPv6")
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; e.g. '--block-port 443')")
"(blocks on IP OR port, for example '--block-port 443')")
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 Expand Up @@ -204,7 +242,11 @@ def config_from_args(args):
try:
s.update(load_config_file(args.config))
except ValueError as e:
_fail(exitcodes.CONFIG, f"invalid config file {args.config!r}: {e}")
# The translated message already says "in the config file" and which
# setting - so this prefix carries the PATH and nothing else. It used
# to say "invalid config file", which put the word "invalid" and the
# words "config file" on the line twice each.
_fail(exitcodes.CONFIG, f"{args.config!r}: {e}")
except OSError as e:
_fail(exitcodes.CONFIG, f"cannot read config file {args.config!r}: {e}")
if args.preset:
Expand Down Expand Up @@ -532,6 +574,27 @@ def _run_session(args, cfg, log, sleep, clock, engine):
if cfg["simulate"]:
log.info("SIMULATION mode (synthetic traffic, no WinDivert).")

# Said BEFORE the divert opens, while stopping still costs nothing. This is
# the mode our own documentation calls the most dangerous, and until now the
# tool started it in silence: measured, `--lat 5` alone impaired 11 844
# packets of a live machine for 202 s before anyone noticed. A
# warning, not a refusal - refusing would break every pipeline that already
# runs this way. Nothing to warn about in --simulate: there is no real traffic.
if not cfg["simulate"]:
warn_if_unbounded(cfg["settings"], log.warn)

# Loaded BEFORE the capture starts, exactly like --dry-run does it. A scenario
# file that cannot be read is knowable without touching the driver, and the
# old order proved it: the run opened the divert, printed "Start.", impaired
# traffic and only then said the file was broken. Failures from RUNNING the
# scenario still land below, where the session can report them properly.
scen = None
if cfg["scenario"]:
try:
scen = load_scenario_file(cfg["scenario"])
except Exception as e:
_fail(exitcodes.SCENARIO, f"scenario error in {cfg['scenario']!r}: {e}")

try:
log.debug("opening the divert...")
engine.start(cfg["filter"], divert=divert, duration=cfg["duration"],
Expand Down Expand Up @@ -560,9 +623,8 @@ def _run_session(args, cfg, log, sleep, clock, engine):

scenario_failed = None
cfg["stop_on_scenario"] = False
if cfg["scenario"]:
if scen is not None:
try:
scen = load_scenario_file(cfg["scenario"])
scen.loop = scen.loop or cfg["loop"]
engine.start_scenario(scen, cfg["settings"], log=log.info)
log.debug(f"scenario: {len(scen.steps)} steps, "
Expand Down Expand Up @@ -751,6 +813,13 @@ def run_cli(argv=None, sleep=time.sleep, clock=time.monotonic, engine=None,
log.debug(f"scenario: {len(scen.steps)} steps, "
f"{scen.duration:.0f}s, loop={scen.loop or cfg['loop']}")
_log_effective_settings(log, cfg)
# A preview that stays quiet about the dangerous shape is a preview
# that misleads: "Configuration is valid" is about each value, and the
# warning is about the SHAPE - impairment armed, nothing aimed at,
# nothing to end it. This is the cheapest place a user can find that
# out, since --dry-run touches neither the driver nor the traffic.
if not cfg["simulate"]:
warn_if_unbounded(cfg["settings"], log.warn)
log.info("Configuration is valid (--dry-run: nothing was started). "
"This checks the settings, not the machine - run --doctor "
"for Administrator rights and the WinDivert driver.")
Expand Down
Loading