ARP: improve policies, guards and neighbors cache. Fix related Fenrir claims. - #154
Merged
Conversation
A sender MAC with the Ethernet group bit set (broadcast/multicast) cannot identify the unicast host a neighbor entry would bind to; accepting it could only blackhole or misdirect traffic. Drop such frames in arp_recv before any opcode processing, for both REQUEST and REPLY. Tests: reply/request with broadcast and multicast sender MAC are dropped (no install, no reply). Four pre-existing tests used 0x01-prefixed sender MACs, which are multicast per IEEE 802; they now use unicast senders.
A neighbor entry is now installed or updated only in answer to a request the stack itself sent (arp_pending_match_and_clear hit). The 'pending || idx < 0' quick-path accepted a first-time install from a single unsolicited reply — one forged frame pre-poisoned any unicast IP, gateway included (wire PoC, security-scan candidate 3d6f7442). Tests: the PoC is imported as unit_tests_arp_regression.c inverted to the fixed behavior (no first-install, no GARP install, gateway TX path unpoisoned, correction requires our own request, overwrite control kept). Three pre-existing tests exercised install/update without a pending request and now record one, matching the policy.
arp_store_neighbor no longer silently drops a new neighbor when MAX_NEIGHBORS slots are taken: it evicts the entry with the oldest timestamp. MAX_NEIGHBORS (4 on several ports) becomes a working-set size instead of a ceiling, so an unsolicited-reply or request flood can churn the cache but can no longer lock out resolution of a live peer until the 120 s aging timeout. arp_neighbor_index also refreshes the timestamp on a successful lookup (use-based aging): live conversations never age out, dead entries are reclaimed at the timeout. The exact boundary is pinned: age == 120000 ms is still live, age == 120001 ms evicts (F-2320). Closes F-3380 (duplicate) as well: the drop-on-full DoS is gone. The two pre-existing full-table tests now pin the eviction behavior.
The tcp_input SYN/LISTEN branch installed (nexthop, raw eth.src) into the neighbor table for any incoming SYN when the slot was empty — one unauthenticated TCP SYN poisoned an arbitrary unicast entry, the gateway variant included (wire PoCs, security-scan candidates 3b4b443e and the F-6036/F-9804 variants). It was a workaround for the request path not learning neighbors, which the pending-only reply gate now supersedes: the peer is resolved through the normal ARP exchange (request we send, reply to it) when we transmit. Cost for brand-new peers: one ARP round trip before the first outgoing segment. The wire PoC is imported as regression fixtures in unit_tests_arp_regression.c inverted to the fixed behavior (SYN accepted, nothing installed; gateway unpoisoned; owner MAC learned only via the solicited exchange).
tcp_send_empty_immediate returned a generic -1 when the neighbor lookup missed and an ARP request went out, so tcp_send_ack never set ack_retry_pending and the pure ACK was dropped with no retransmission path. Return -WOLFIP_EAGAIN in that case so the retry is scheduled and the ACK goes out once the request resolves. Regression test: saturated TX FIFO (large pushes plus a minimal top-up so no ACK-sized slack remains) + unresolved nexthop: the retry flag is set and the request is sent; the solicited reply installs the entry and the next tcp_send_ack transmits and clears the flag.
arp_queue_packet clobbered slot 0 when all WOLFIP_ARP_PENDING_MAX entries were taken by other destinations, silently discarding the oldest pending frame for an unrelated destination. The entry carries no timestamp, so evict-oldest would need new state; dropping the new frame is zero-state and the upstream protocol retransmits it. The two pre-existing tests pinned the slot-0 fallback and now pin the drop instead.
A DHCPACK did not verify the offered address was free: the client bound immediately and only learned, through ARP churn, if some other host was already using the address. After a successful DHCPACK the client now enters a new DHCP_DAD state (RFC 4331): it sends three gratuitous-ARP-style probes (ARP request with sip = 0.0.0.0 and tip = the offered address) one second apart. A probe answered by a host with a different MAC is a conflict: the client sends a DHCPDECLINE (RFC 2131 4.4) carrying the address, releases the configuration, and restarts at DISCOVER. A reply with our own MAC is our own probe looping back and is ignored. If no foreign answer arrives the lease binds and the normal renewal/rebind timers take over; the lease timers are armed at ACK time so a conflict never leaves them dangling. The DAD reply hook in arp_recv is the one case where a reply claiming our own IP is acted on: during DAD it means the address is taken. The probes bypass the 1 req/s ARP rate limit on purpose: DAD is at most three probes per acquisition and must not starve behind ordinary traffic. Non-ethernet builds keep the previous bind-immediately behaviour (no L2 to probe on).
Two issues found by the pre-PR self-review: 1. Stale renew timer: parse_ack armed the lease timer (which inserts the renew timer into the shared timer heap) and then scheduled the DAD timer on top, leaving the renew timer in the heap. handle_timers() fires every expired entry without id matching, so at renewal the callback would run twice: a duplicate renewal REQUEST and an extra DAD cycle per renewal. The renew timer is now swapped out of the heap (absolutes kept) before the DAD timer is armed; DAD completion re-arms it. Pinned by test_dhcp_dad_single_dhcp_timer_in_heap, verified red (count=2) without the fix. 2. Non-ethernet builds: dhcp_send_decline and dhcp_dad_conflict are only reachable from the ethernet DAD path; guard them with #ifdef ETHERNET so the no-eth target does not trip -Werror=unused-function. (The no-eth target still fails on pre-existing iphdr_set_checksum/nexthop errors present at 50b7bb7, untouched here.) The DAD-completion helper in the tests now drives the real handle_timers() dispatch so fired timers are popped exactly as in production.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR strengthens ARP/DHCP behavior in wolfIP by tightening neighbor-cache update policy, improving cache/queue eviction behavior, and adding RFC 4331/5227-style DHCP address conflict detection (DAD) after DHCPACK to address a set of Fenrir claims/regressions.
Changes:
- Add DHCP post-ACK DAD state machine (ARP probes, conflict handling, and DECLINE) before binding the lease.
- Harden ARP neighbor learning: require a pending request for ARP reply installs, reject group (bcast/mcast) sender MACs, remove SYN-driven neighbor learning, and implement LRU eviction + use-based aging.
- Adjust TCP pure-ACK handling on ARP miss to return
-WOLFIP_EAGAINso callers can schedule a retry; add/extend unit/regression coverage for the new behaviors.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/wolfip.c | Implements DHCP DAD + DECLINE path, tightens ARP reply install policy, adds neighbor LRU/aging refresh, and updates TCP pure-ACK retry behavior. |
| src/test/unit/unit.c | Registers new/renamed unit tests for ARP regressions, DHCP DAD, and pure-ACK ARP-miss retry. |
| src/test/unit/unit_tests_tcp_ack.c | Updates ARP tests to reflect pending-only install gate and queue-full drop behavior; adds neighbor aging/eviction coverage. |
| src/test/unit/unit_tests_proto.c | Updates ARP reply tests to record pending requests and adjusts MACs to satisfy new unicast-sender requirement. |
| src/test/unit/unit_tests_ip_arp_recv.c | Adds coverage ensuring group sender MAC ARP frames are dropped (request and reply). |
| src/test/unit/unit_tests_dns_dhcp.c | Updates DHCP ACK tests to complete DAD before asserting bound behavior. |
| src/test/unit/unit_tests_dhcp_edges.c | Adds DHCP DAD unit tests and updates existing ACK tests to transition through DAD. |
| src/test/unit/unit_tests_branches.c | Updates branch tests for ARP queue-full drop behavior and LRU eviction semantics. |
| src/test/unit/unit_tests_arp_regression.c | New regression suite asserting pending-only neighbor installs and “no SYN-driven learning” behavior. |
| src/test/unit/unit_shared.c | Adds dhcp_test_complete_dad() helper to drive DAD completion in tests. |
| Makefile | Adds new ARP regression test source to the unit-test build list. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Merging current master exposed two tests that implicitly depended on the removed unsolicited-ARP learning: - test_accept_synack_retransmit_repeats_isn (F-8514): the passive-open SYN-ACK is queued in the tx FIFO until the peer MAC is known. On master the SYN-learning store installed the peer when the SYN arrived, so the SYN-ACK went out on the first poll. With learning restricted to answers of our own requests, the flush sends an ARP request and parks the segment. The test now answers that request with an ARP reply (the peer MAC inject_tcp_syn used) before asserting on the wire - which also exercises the pending-only gate on the way. All ISN assertions are unchanged. - test_vlan_rx_unfiltered_arp_reply_learns_neighbor: asserted that an unsolicited VLAN-tagged reply installs a neighbor, exactly the behavior the policy removes. It now asks for the address first (arp_request on the subinterface, past the fresh-stack rate-limit window) and verifies the VLAN RX path still delivers the tagged reply to the subinterface's arp_recv, which then installs it. Verified: unit 1440/0, unit-multicast 1474/0, unit-vlan 1480/0.
Real ll drivers (stm32, lpc, fman, gem, tap) return the frame length on send success, not 0. parse_ack checked the first DAD probe against == 0, so on the stm32 m33mu target the probe was never counted and the state machine sent a fourth probe, delaying BOUND a full extra interval (t0+4000 instead of t0+3000). Combined with the ~3s environmental OFFER delay in the CI tap setup, total acquisition exceeded the app's 5000-tick DHCP safety timeout: the device fell back to the static IP while the test still pinged the leased one. - parse_ack: treat any non-negative send return as "sent". - stm32h563 main.c: raise the DHCP safety-net timeout 5000 -> 10000 to cover OFFER delay + RFC 4331 DAD with margin. - New unit test pins the len-returning driver contract: exactly 3 probes then BOUND (red without the fix: probes stayed 0). Verified: unit 1441/0, unit-multicast 1475/0, unit-vlan 1481/0, unit-noeth unchanged (pre-existing errors only); m33mu echo job repro in the wolfboot-ci-m33mu container: leased 192.168.12.93, 3 DAD probes on the wire, BOUND on the leased IP, echo OK.
Copilot review on PR 154: a DECLINE must not put the declined address
in ciaddr - the client never bound it, so ciaddr stays 0.0.0.0 and the
address travels in option 50 (Requested IP), the interop-safe form
(RFC 2131 §4.4). dhcp_send_decline now emits option 50 alongside the
server ID.
Also fixes the duplicated word in the arp regression fixture header
comment ("candidate candidate-...").
New test_dhcp_decline_wire_format pins the DECLINE on the wire:
op=BOOT_REQUEST, ciaddr=0, msg-type=DECLINE, option 54 server,
option 50 = the declined address.
Verified: unit 1442/0, unit-multicast 1476/0, unit-vlan 1482/0,
unit-noeth unchanged (pre-existing errors only).
The ARP request rate limit keyed on last_arp + 1000 > last_tick with last_arp starting at 0 (BSS), so a device that never sent an ARP request in the current tick domain held its first request back for a full window. Under the emulated (paced) tick rate that is several seconds of wall time; on a clean boot the first resolution request is the one that matters (pending-only learning means no entry exists until we ask). last_arp == 0 now means "never sent" and the first request always goes out; a send stores last_tick + 1 so a request sent at tick 0 is distinguishable from "never sent". wolfIP_poll detects a tick-source restart (now < last_tick, e.g. an app handing off from a bare-metal tick loop to an RTOS tick) and resets the per-interface limiter: absolute tick values from the previous domain are not comparable, and a stale last_arp would hold the first request of the new domain for the whole stale offset. Adds regression tests for the first-request and tick-restart paths.
The TCP flush rewrites a parked control segment's ack from the socket current state before sending (correct for same-connection retransmits). When a listener socket is reverted to LISTEN for the next connection while a half-open connection's SYN-ACK is still parked, that refresh retransmits the stale SYN-ACK with the new connection's ack value and a wrong destination port: the peer RSTs, the next handshake lands on the listener socket itself, and the app's accepted clone is left without a valid connection. Clear the parked segments at every revert-to-LISTEN path: the accept() clone, the RST listener fallback, and the RTO max-retries recovery. The accepted clone carries its own SYN-ACK, so discarding the listener's copy is safe. Adds regression tests, including an end-to-end two-connection test that observes the stale SYN-ACK on the wire without the fix.
The harness started the nc retry burst once the host granted the lease (the DHCP ACK), but the device may still be running RFC 4331 DAD before it binds and starts serving. Under the emulated tick rate that window is several seconds, longer than the burst, so the test gave up while the device was still legitimately probing (the plain stm32h563 job failed on any branch with DAD). wait_for_lease now also waits, bounded by the same retry count, for the plain app's own ready line once the lease exists. Apps that print no DHCP marker (FreeRTOS builds) keep the old behavior.
The stm32h563-m33mu workflow carries inline copies of the test scripts instead of calling tools/scripts/run-m33mu-ci-in-container.sh (only the FreeRTOS workflow does), so the readiness wait added there never reached the plain echo job: the nc burst still started at the host lease and gave up before the device finished RFC 4331 DAD and started serving. Add the same bounded wait for the app's ready line to the echo, full, and https_tls13 jobs.
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/wolfip.c:9084
- arp_neighbor_index() now refreshes the neighbor timestamp unconditionally on any successful lookup. This means even callers doing a conditional refresh (e.g. arp_recv()'s ARP REQUEST path, which intends to refresh only when the sender MAC matches the cached MAC) will still extend the lifetime of an entry on MAC mismatch, making it easier to keep a stale/poisoned mapping from aging out.
if (s->arp.neighbors[i].ip == ip && s->arp.neighbors[i].if_idx == if_idx) {
if (s->last_tick >= s->arp.neighbors[i].ts &&
(s->last_tick - s->arp.neighbors[i].ts) > (uint64_t)ARP_AGING_TIMEOUT_MS) {
s->arp.neighbors[i].ip = IPADDR_ANY;
s->arp.neighbors[i].if_idx = 0;
s->arp.neighbors[i].ts = 0;
memset(s->arp.neighbors[i].mac, 0, 6);
return -1;
}
s->arp.neighbors[i].ts = s->last_tick;
return i;
gasbytes
approved these changes
Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Featuring the following addressed claims:
d146f2a F-4045: self-review fixes for the DAD state machine
d7d2199 F-4045: run RFC 4331 DAD after DHCPACK before the lease binds
64288da F-4057: drop queued frame when the ARP pending queue is full
9ba8611 F-10261: retry the pure ACK when the immediate send misses ARP
99fb19b F-6035: remove SYN-driven ARP neighbor learning
3c194d9 F-6212: evict the least recently used neighbor when the table is full
88c8feb F-9805: require a pending request for ARP reply installs
b5fd480 F-10274: drop ARP frames with a group sender MAC