Skip to content

icmp - #1760

Draft
daniel-noland wants to merge 24 commits into
pr/daniel-noland/bench-harnessfrom
pr/daniel-noland/icmp-and-acl
Draft

icmp#1760
daniel-noland wants to merge 24 commits into
pr/daniel-noland/bench-harnessfrom
pr/daniel-noland/icmp-and-acl

Conversation

@daniel-noland

@daniel-noland daniel-noland commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from 65dccd7 to 640edd0 Compare August 26, 2026 17:30
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch 3 times, most recently from d200140 to e431baa Compare August 26, 2026 20:41
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from 1ca904b to 7ae0ae2 Compare August 26, 2026 21:02
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch from e431baa to 30ad436 Compare August 26, 2026 21:02
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from 7ae0ae2 to afe8933 Compare August 26, 2026 21:13
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch from 30ad436 to 972ede1 Compare August 26, 2026 21:13
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from afe8933 to ab17860 Compare August 26, 2026 21:25
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch from 972ede1 to 86a422e Compare August 26, 2026 21:25
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from ab17860 to b0a5e97 Compare August 27, 2026 01:29
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch from 86a422e to 40180ec Compare August 27, 2026 01:29
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from b0a5e97 to c2c48ab Compare August 27, 2026 01:41
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch from 40180ec to ebe87d1 Compare August 27, 2026 01:41
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from c2c48ab to 61fb4c7 Compare August 27, 2026 04:35
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch from 5d8566c to ee0870c Compare August 27, 2026 04:36
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from 61fb4c7 to b18bba9 Compare August 27, 2026 05:12
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch from ee0870c to d39b0d7 Compare August 27, 2026 05:12
@daniel-noland daniel-noland mentioned this pull request Aug 27, 2026
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from b18bba9 to 9a66fc6 Compare August 27, 2026 18:00
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch from 543ccfc to 792d63f Compare August 27, 2026 18:00
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from 9a66fc6 to 4517822 Compare August 27, 2026 18:29
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch 2 times, most recently from 6c9c867 to d4744be Compare August 28, 2026 02:13
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from 4517822 to bb9404d Compare August 28, 2026 02:13
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch from d4744be to f33dfe6 Compare August 28, 2026 02:19
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch 2 times, most recently from 54309b8 to b721f06 Compare August 28, 2026 03:05
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from 5f95720 to 5a2aaf7 Compare August 28, 2026 07:43
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch from 7e9ebd7 to 8f3aea3 Compare August 28, 2026 07:43
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from 5a2aaf7 to 2777622 Compare August 28, 2026 09:14
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch from 8f3aea3 to 49c0eda Compare August 28, 2026 09:14
daniel-noland and others added 24 commits August 28, 2026 11:14
…ts on

RFC 5508 REQ-6, found by tracking the specification the ICMP handler was
already arguing from in prose. An ICMP error is trivially spoofable and
masquerade releases the public address and port along with the flow, so
honouring every Destination Unreachable handed an off-path attacker both a
way to break a ping it cannot observe and a way to churn the port pool.

The interlock's remaining survivor on this citation is the IPv6 arm, which
no test reaches: masquerade carries a v6 pool and the nat suite has no IPv6
case at all.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `TypeGenerator` contract is that a generator eventually reaches every
legal value; this one documented that it did not reach `Ipv4::options`, so
no property in the workspace has ever seen a variable-length IPv4 header.

`parse_back` is why it stayed that way: it sized its buffer to `MIN_LEN` and
asserted the deparse wrote exactly that, which is the generator's limitation
restated as a requirement. The whole workspace passes once both are fixed.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`is_icmp_unrecoverable` decides whether an ICMP error costs the flow it
reports on, and no test said anything about it: the whole function could be
replaced by `(true, None)` or `(false, None)` and the suite passed. The
distinction it draws is the one that matters for availability -- Fragmentation
Needed is Path MTU Discovery, and the sender is about to retry the flow that
tearing it down would strand.

The fixture builder took the Destination Unreachable code as a constant, so
the recoverable case could not be built at all.

Ten survivors in this file become three, all of them IPv6.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pools, the allocator and the stage are generic over the address family
and `build_pool66` mirrors `build_pool44` exactly, which is the argument that
kept the IPv6 instantiation of all three untested: the nat crate had no IPv6
case at all. It translates and untranslates correctly.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turning a usage bitmap into port ranges is the allocator's answer to "what is
allocated", and 28 of the crate's surviving mutants were in it: the arithmetic
that turns a bit offset into a port number, the join between a block's two
halves, and the fold across blocks were all unstated. Nothing said a range had
to be maximal, or that folding blocks in a different order gave the same
answer -- which is the order the caller actually uses, since it iterates a
`HashMap`.

The two bounds in `merge_ranges` guard additions next to the edges of `u16`,
and a property confined to low ports reads them as no-ops; drawing blocks from
the whole port space is what makes them die.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The RFC 5508 REQ-6 citation was decorative on IPv6: the arm of the predicate
that recognises an embedded ICMPv6 Query could be deleted outright and the
suite passed, because no ICMPv6 fixture existed. The interlock reported this
as `unreached` rather than as a survivor, which is what said the fix was to
change what the test feeds.

Path MTU Discovery needed its own IPv6 test rather than a parameter: v4 spells
the recoverable case as a Destination Unreachable code and v6 as a separate
type, so one test cannot reach both arms.

REQ-6 now holds under `just spec-interlock`. What survives in this file is the
Packet Too Big guard, which decides nothing the fallback would not; the reason
is recorded next to it.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… itself

Three things the forwarding path decides that nothing asserted.

Nothing said ECMP spreads. The entry selector could be replaced by "always
entry zero" and the suite passed -- a router forwarding every flow down one of
its paths, which looks healthy until a link saturates. Stability is asserted
alongside it, because a flow whose packets take different paths reorders
itself and splits in two for anything downstream that keys on the path.

`is_iplocal` and `is_vxlan_with_vni` are read on the forwarding path and had
no test at all. Both are stated against a second reading of the same fact
rather than against a restatement of their bodies.

`> 1` in the selector is equivalent to `>= 1` and the reason is recorded next
to it; `== 1` and `< 1` are the ones that matter, and now die.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RFC 5508 REQ-3 and its (a) and (c) clauses. The code already did the right
thing and the comment already named the RFC; what was missing was a test that
could tell a validator checking too much from one checking too little. The
existing test breaks every checksum at once, so it cannot.

Both sub-clauses need the ICMP checksum recomputed after the field under test
is broken, because it covers them. That is not a trick to make the test pass:
without it the outer check fires first and neither clause is ever reached, and
with it the packet is the one a real reporter produces.

Cited in `net` rather than on the existing `nat` test, because the interlock
declines a citation whose implementation and test are in different crates.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RFC 8200 puts extension headers between the IP header and the transport, so
for IPv6 the IP header's next-header field names the first extension header
rather than what the packet carries. Both filters matched their rules against
that field.

In `acl-filter` this is a bypass: a Deny rule naming TCP stops applying to any
TCP packet with one Hop-by-Hop header in front of it, and the sender chooses
whether there is one. In `flow-filter` the same mistake fails the other way,
dropping traffic a protocol-restricted expose was configured to carry -- which
had been written down as a characterization test rather than recognised as the
same defect.

`upper_layer_proto` returns `None` when the chain runs past MAX_NET_EXTENSIONS
rather than guessing, because a filter has to decide what to do about a chain
nobody finished reading. Both callers drop.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The case only exists on the wire -- the header builder caps at
MAX_NET_EXTENSIONS -- so the fixture is bytes. That is also the only place an
attacker writes.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RFC 4884 section 4.6 gives one list per address family and they differ:
ICMPv4 Parameter Problem may carry an extension structure and ICMPv6
Parameter Problem may not, because its bytes 4..8 are the Pointer and there is
nowhere to put a length attribute. The IPv6 list was the IPv4 list.

The consequence is a pointer read as a length: any ICMPv6 Parameter Problem
whose pointer exceeds 255 announces an "original datagram" of up to 2040
octets, and `check_full_payload` decides whether the embedded packet is
complete against that number.

Same shape as the RFC 4884 defect this branch opened with -- a near-duplicate
where the specification is not symmetric -- so both lists are now stated, and
the citation needs both tests.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both stages reached into the headers for the two layers they care about, which
means every layer they do not name is silently along for the ride. `pat` was
built to make that impossible -- it fails the match when an unnamed layer is
present, and VLAN tags are strict there because they decide forwarding -- and
both stages bypassed it.

A VLAN tag is not an evasion: it sits ahead of the IP header, so the protocol
and ports a rule matches on were always right. The problem is the other side.
Nothing after these stages reads `headers.vlan` -- `Egress` rewrites the MACs
and leaves it, VXLAN re-encapsulation puts the outer headers in front of it --
so a tag chosen by whoever built the inner frame of a tunnelled packet would be
forwarded onto whatever segment it names, decided by nobody. There is no
configuration that expresses an opinion about one.

The cost is one `ArrayVec::len` comparison: `opt_eth` and `opt_transport` are
`Option` maps over fields both stages already read, `ext_gap_ok` for `Net` is a
constant `true`, and `step` is `#[inline]`. Everything else is the type-level
accumulator.

`upper_layer_proto` stays, and the two are complementary rather than redundant:
`pat` skips extension headers at the network position on purpose, so a chain
that ran past MAX_NET_EXTENSIONS still matches and only `upper_layer_proto`
reports that the transport was never reached.

The flow-filter oracle read the protocol out of the same field the classifier
did, so correcting one required correcting the other -- and that emptied the
suite's "non-transport protocol" bucket, which had been filled entirely by
extension-header stacks being misread. `V4ExoticProto` supplies a real one.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Headers::size` counts the VXLAN header, because it is one of them and
`headers.deparse` writes it. Both fixtures added it again by hand and sized the
buffer for both copies, so eight stray octets sat in front of every inner
frame. Decapsulation read them as the start of an Ethernet header and stopped
there, which the existing tests could not see: they decapsulate and re-encap to
compare *outer* DSCP and ECN, and never look at what came out.

Found by writing the first test that does look, which is also what the
overlay's refusal of VLAN-tagged frames rests on -- if decapsulation stripped
tags rather than handing them on, the check in `IpForwarder` would be dead code.

The refusal goes at the decapsulation boundary rather than at the first stage
that happens to look, so a tag never becomes an overlay packet: `IcmpErrorHandler`
and `FlowLookup` both run before the filters that would otherwise refuse it.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule is that a stage matches the shape of the chain it acts on rather than
reaching in for the fields it wants, so that a layer nobody considered cannot
be answered over. Two defects the same week made the case, failing in opposite
directions: a field read as something it was not, and a field not read at all.

The overlay's refusal of VLAN-tagged frames is a consequence of the rule and
not an architectural position. VLAN inside VXLAN is legitimate traffic; nothing
here is equipped to carry it, which is a different claim and a smaller one.
Written down with what changing the answer would take, because the refusal was
phrased in a way that read as permanent -- and the same phrasing would have
been wrong about MPLS, or about whatever comes next.

Naming the layer in the pattern is how a stage says it has been taught. That
the untaught stages keep refusing without anybody listing them is the point.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Storing the incremental update made these writes real for the first time, and
with them the write-side rules the old no-op never had to obey.

A UDP datagram over IPv4 may carry a zero checksum to say the sender computed
none. There is no sum there to fold a delta into, so translating a port or an
address of such a quote turned the marker into a checksum for a sum nobody took.
Zero is spoken for on the other side too: over IPv6 the field is mandatory, so a
fold that lands on zero goes out as the other spelling instead.

Which rule applies depends on the IP version of the packet the header was quoted
from, which `EmbeddedTransport` cannot see, hence the new argument.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cksum

`update_checksum_for_address` was added with no caller. Wiring it is the point:
TCP, UDP and ICMPv6 are checksummed over a pseudo-header built from the quoted
packet's addresses, so rewriting one and stopping there leaves the quote
describing an address that is no longer in it.

The fold is its own step rather than part of port translation because a mapping
that moves only an address never reaches the transport header otherwise -- the
port path returns first when there is no port to move.

Nothing on the wire catches this. RFC 5508 REQ-3(c) tells a NAT not to validate
the quoted transport checksum of an error it receives, so a wrong one survives
to the end host that finally reads the quote. Hence an oracle built from
scratch: the tests compare against a fixture that computes these checksums
rather than folding deltas into them.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same dropped return value as `EmbeddedTransport::update_checksum`, at a site
that path does not run through: an ICMP quote's identifier is translated on the
header directly, so it kept its own copy of the bug after the shared one was
fixed.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Standing aside for a live incumbent asks the table to hold nothing, so a full
table has no reason to refuse it -- and refusing turned the one answer a racing
caller can act on into an error it can only drop a packet over, precisely where
races are most likely.

`admit` reads the table's length, and `DashMap::len` read-locks every shard,
including the one an entry guard holds. Hence the length is read up front and
consulted inside the vacant arm alone, rather than the admission check simply
moving down.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`insert_if_absent` was added with no caller, so the race it describes was still
open: two packets of one new flow, handled at the same moment on two cores, each
allocated a public tuple and each installed a pair. The second displaced the
first's forward half, and the first's reverse half -- keyed on an allocation
nothing else can collide with -- stayed live, translating for a tuple that goes
back to the pool with the half that was displaced and is then handed to somebody
else.

The loser keeps nothing. Its reverse half is never inserted, and dropping the
pair it built releases the allocation through `AllocatedPort`, so there is no
hand-written release path to keep in step. It masquerades with the winner's
flow, which is the answer the packet after it would have got anyway.

Re-checking and teardown stay with whoever installed the flow. A flow won by
another packet was built from an allocator handle of its own, and judging it by
this packet's could invalidate a sound flow over an allocator it never used.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No concurrency is needed to reach this one. `process_packet` takes the slow path
on the stamp alone -- `get_packet_port_fw_state` reads what `FlowLookup`
attached and never falls back to the table, unlike masquerade -- and a burst is
stamped before any of it is forwarded. Two packets of one new flow in a single
burst therefore both build a pair, and the second tears the first's down.

Sequentially that ends consistent, which is why it went unseen: the second pair
carries the same translation, so a connection that has been rebuilt underneath
its own first packets forwards exactly like one that was left alone. Two cores
end it worse. Displacing a flow invalidates its partner, so a loser's reverse
insert landing after the winner's strikes down the winner's forward half and
then stays live under it -- a pair that answers replies it can no longer
forward.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`insert_if_absent` decides by asking whether the incumbent `is_active()`, and
`FlowInfo::new` starts a flow at `Detached`. Publishing the status after the flow
is reachable leaves a window in which it is in the map and still reads as a
corpse: a caller landing in that window displaces a live flow and reports
`Installed` too.

Two winners for one key is what this method exists to prevent. Both then install
a reverse flow, and the loser's reverse outlives the allocation it maps -- which
is the failure the masquerade and port-forwarding arbitration in the two commits
below this one were written to close.

`insert_common` does not need it. Its window is harmless because the only reader
of the status is `drain_stale`, which also requires the deadline to have passed,
and a fresh flow expires in the future. This method is the reader that window
became visible to.

Set before taking the entry guard rather than under it, which is the obvious
answer and a trap: `update_status` writes a `concurrency` atomic, so it is a
scheduling point under shuttle, while `dashmap` is a plain dependency whose shard
locks shuttle cannot see. A preemption inside a lock the model checker does not
know about parks every green thread on the one OS thread -- the suite deadlocks
at 0% CPU rather than reporting anything. Nothing observes `val` before it is
inserted, so publishing early costs nothing; it only has to be undone on the two
paths that do not insert, and those are outside the guard.

That is also why there is no regression test. A shuttle property over two threads
inserting one key hangs for the same reason; instrumenting `dashmap` is the
prerequisite, and the doc comment now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
`fix(acl-filter,flow-filter): Match on the protocol the packet carries` converted
the two filters and left `nat`. For IPv6 the IP header's next-header field names
the first extension header rather than the transport, so:

  * `is_port_forwardable` is false for a TCP packet with one Hop-by-Hop header in
    front of it, and the packet is refused as unsupported traffic;
  * `PortFwKey` names the extension header, so the forward and reverse rule
    lookups find nothing.

Between them that is a published service being unreachable over IPv6 from any
sender that inserts one extension header -- and the sender chooses. It is the
same failure the `flow-filter` half of that commit describes: dropping traffic a
protocol-restricted expose was configured to carry.

`next_flow_status` had the same read behind a different argument: the old comment
defended it on the grounds that transport headers may be absent with
fragmentation. `upper_layer_proto` walks the chain, fragment header included, so
it answers that rather than trading it away; the fallback keeps the old answer
for a chain that ran past MAX_NET_EXTENSIONS.

`is_icmp` keeps its IP-version check alongside the protocol, so ICMPv4 in IPv6
stays unsupported rather than becoming translatable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
…l too

`Match on the protocol the packet carries` converted `get_rule_from_pkt_fw_path`
and `_rev_path` and left `can_be_port_forwarded`, which is the path a flow takes
before it has any state -- so the failure that commit describes survived in the
place it does the most damage.

`.net().transport()` is permissive about extension headers, so an IPv6 TCP packet
behind a Hop-by-Hop header reaches the qualifying arm and is keyed on Hop-by-Hop.
No rule matches, and a published service is unreachable over IPv6 from any sender
that inserts one extension header -- which the sender chooses.

The regression test asserts on the flow pair rather than the translation, because
the pair only exists if a rule was found.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/bench-harness branch from 2777622 to 3b54931 Compare August 28, 2026 17:16
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/icmp-and-acl branch from 49c0eda to 9e5d2cf Compare August 28, 2026 17:16
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