Skip to content

feat(core): aim the tool at one address family, without touching the default - #169

Merged
donislawdev merged 6 commits into
masterfrom
feat/ip-family-targeting
Aug 31, 2026
Merged

feat(core): aim the tool at one address family, without touching the default#169
donislawdev merged 6 commits into
masterfrom
feat/ip-family-targeting

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

What and why

The tool captures IPv4 and IPv6, and that was not free: the filters once began
with ip and ..., which in the WinDivert language means IPv4 ONLY, so every IPv6
packet went past uncounted and unlisted. This adds a way to aim at one family on
purpose, and treats the default it could undo as the thing most worth guarding.

The default is untouched, and there is a test that says so rather than a
promise.
With neither switch on, every driver filter is unchanged character for
character, both families are impaired exactly as before, and the summary line and
the reproduction command gain nothing.

Where it lives, and why not where it first looked like it belonged

Two checkboxes in the destination card, under IP: and Port::

IP:    [                                   ]
Port:  [                                   ]
[ ] IPv4 addresses only    [ ] IPv6 addresses only

The obvious place was next to LAN mode and Internet only. Measured, those two
are impairs=all and genuinely CUT traffic in decide() step 2b, so a checkbox
borrowing their place inherits their meaning: the first reading of "IPv4 only"
under them is "IPv6 is blocked", which is the opposite of what it does. The
destination card contains nothing that drops, and a family is a property of the
address beside it.

The tooltip leads with the half a reader gets wrong: the other family keeps
flowing, is not blocked and not slowed, and this applies with the IP field empty
too. The GUI needed no code - the registry places both on one row by itself.

How it behaves

  • An out-of-family packet takes the same exit as one outside the destination
    target: passed through untouched, scoped=False.
  • Both switches on is a legal request meaning "nothing qualifies", stored as an
    empty set so the contradiction needs no case of its own. It is logged once per
    apply, next to the identical warning for LAN mode plus Internet only, in the
    words that matter here: nothing will be IMPAIRED, not nothing will get through.
  • An address the gate cannot read has no family and is left alone while one is
    chosen - the direction utils.is_lan_ip already takes.
  • ::ffff:1.2.3.4 counts as IPv6, which is what it is on the wire.
  • narrows=False deliberately: one family still reaches every connection of that
    family on the machine, so marking it as a bound would silence the blast-radius
    warning for a session that has bounded nothing.

The ceiling, and what it forced

core.decide sat exactly on the pinned complexity ceiling of 29, where the rule
beside the number is that splitting lowers it rather than raising it. The family
gate would have been a thirtieth way through. Its three targeting tests asked one
question and returned one identical verdict, so they moved into
core._out_of_scope, and max-complexity went 29 -> 27 in the same change.
The PLR0912 figure quoted next to it was re-measured (30 -> 28) rather than left
to drift. 105 core and scope assertions pass across the fold unchanged.

Deliberately not in here

The WinDivert filter. decide() is the authority and folding a filter conjunct
is the opt-in optimisation, exactly how the destination fields already work.
Doing it needs ping parenthesised (icmp or icmpv6 is a top-level OR), the
compile-length limit respected, and a rule for the contradictory pair - its own
change with its own measurements. --simulate generates IPv4 only, so
--ipv6-only impairs nothing there, which is the same answer a machine without
IPv6 gives; recorded rather than papered over.

Verification

  • The guards for every surface touched: core, registry, settings, CLI, repro,
    summary, view scope, GUI state, engine, docs (348 assertions)
  • ruff check . and mypy beantester
  • python smoke_gui.py
  • python tools/ci_gui_render.py on real Tk at 1366x768: en, pl and zh clean
  • Opened the real app and expanded the card: both checkboxes on one row, the
    tooltip readable, Polish diacritics intact
  • Full suite left to CI, on both runners

Eleven CLI runtime tests went red on the way here, on a fake engine that did not
know the new setter. Checked against master first - green there - so "mine or
already broken" was answered by measurement. The doubles were the one consumer
the analysis had not enumerated.

No version bump.

…to fit it

The engine can now be told to touch IPv4 only or IPv6 only. An out-of-family
packet takes the same exit as one outside the destination target: passed through
untouched and NOT in scope. It is not blocked and not slowed, which is the whole
distinction the switch has to survive being misread about.

Adding this would have been a thirtieth way through decide(), and that function
sat exactly on the pinned complexity ceiling, where the rule is that splitting
lowers the number rather than raising it. Its three targeting tests asked one
question and returned one identical verdict, so they moved into _out_of_scope()
and the ceiling came down with them: 29 -> 27, measured, and the PLR0912 figure
quoted next to it re-measured too rather than left to drift. 105 core and scope
assertions pass unchanged across the fold.

Both switches at once is a legal request meaning "nothing qualifies", stored as
an empty set so the contradiction needs no case of its own. Saying out loud that
it is probably a mistake belongs to apply_settings, next to the identical warning
for LAN mode plus Internet only; refusing it would break a run somebody meant.

An address the gate cannot read has no family and is left alone while one is
chosen - the direction utils.is_lan_ip already takes. A mapped address
(::ffff:1.2.3.4) counts as IPv6, because that is what it is on the wire.
…e reports

Two BOOL fields in the DESTINATION card, next to the IP and Port they refine -
not next to LAN mode and Internet only. Those two CUT traffic; these two only say
what is aimed at, and a checkbox that borrows their place inherits their meaning:
the first reading of "IPv4 only" under them is "IPv6 is blocked", which is the
opposite of what it does.

The registry entry, the CLI flag and the seven translation keys land in one
commit because the guards refuse anything less: a field without --ipv4-only, or
without a label in all three languages, reddens test_field_registry on its own.
The flags are --ipv4-only / --ipv6-only, mirroring --internet-only, and no
abbreviation collides (there is no other option starting with --o or --ip).

Not narrows=True, deliberately: "IPv4 only" still reaches every IPv4 connection
on the machine, so marking it as a bound would silence the blast-radius warning
for a session that has bounded nothing.

Both switches at once is logged once per apply, beside the identical warning for
LAN mode plus Internet only, in the words that matter here: nothing will be
IMPAIRED, rather than nothing will get through.

The default is untouched and the rendered output says so: "Active: 10% loss." and
`--loss 10`, with no fragment and no flag added.
Capturing IPv6 was not free here: the filters once began with `ip and ...`, which
in the WinDivert language is IPv4 ONLY, so every IPv6 packet went past the tool
uncounted and unlisted. A switch that can ask for exactly that state deserves a
guard on the state it starts in, rather than a promise in a commit message.

Four assertions, each on a surface a regression would show up on: every driver
filter still carries both families, character for character; both fields default
to off; a default session impairs an IPv4 and an IPv6 packet alike; and the
summary and the reproduction command gain nothing at the default, with the
opposite direction asserted too so it cannot pass by the flags never existing.

The filter strings are literals rather than values read from the code under test.
A guard that builds its expectation the way the code does agrees with the code by
construction, including when the code is wrong.

The GUI needed no code: the registry places both checkboxes on one row under IP
and Port by itself. Verified on real Tk at 1366x768 in all three languages.
…them

Both READMEs get the flag row next to --lan-mode, and the guard that demands it
is why this is one commit and not a forgotten follow-up: test_cli_docs reddens on
an undocumented flag.

The user-facing line leads with what the switch does NOT do, because that is the
half a reader gets wrong: the other family keeps flowing, nothing is blocked and
nothing is slowed. The internal entry records what was deliberately left out -
the WinDivert filter, and the reasons folding the family in there is its own
change - so the next session does not read the omission as an oversight.
Eleven CLI runtime tests went red on a fake engine with no set_ip_family, and
the failure looked nothing like its cause: the blast-radius warnings stopped
appearing, because the run died before reaching them.

The doubles are a consumer of the engine's interface, and they were the one
consumer the analysis for this change did not enumerate. Adding the method is
the whole fix - checked on master first, where the same file is green, so the
question "is this mine or was it already broken" was answered by measurement
rather than by assumption.
All three fired on CI, all three were right, and two of them are the opposite of
what their numbers look like.

build_arg_parser passed the size ceiling (135 > 133) when the two flags landed,
and the ratchet's message says what to do rather than leaving it to taste: split
it, do not raise the ceiling. The seam was already there. _add_scope_arguments
now holds the ten flags that say WHICH traffic is aimed at - process,
destination, address family, address class, blocking - and what stays behind says
what is done to it.

settings_summary had gone 25 -> 27 on two new branches, which is exactly the
creep that ratchet exists to catch. Four plain on/off switches that each append
one fixed phrase are now one loop over a table: back to 25, and a fifth switch
costs nothing anything counts.

COMPLEX_NEAR_CEILING moves 3 -> 5 and nothing grew into that band. Lowering
max-complexity to 27 moved the 70% line from 20 to 18, so _capture_loop (20) and
_module_level (19) are inside it without changing by a line. Re-measured and
written down, because the count alone reads like a regression. The mutation that
proves the guard can fail was re-anchored onto the new constant.

Found by CI and not locally: the run here covered the surfaces this change
touched, and these three scan the whole tree.
@donislawdev
donislawdev merged commit cbab21d into master Aug 31, 2026
14 checks passed
@donislawdev
donislawdev deleted the feat/ip-family-targeting branch August 31, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant