Skip to content

netstack: let ICMP echo handlers consume requests#13227

Merged
copybara-service[bot] merged 2 commits into
masterfrom
test/cl919087849
May 21, 2026
Merged

netstack: let ICMP echo handlers consume requests#13227
copybara-service[bot] merged 2 commits into
masterfrom
test/cl919087849

Conversation

@copybara-service
Copy link
Copy Markdown

netstack: let ICMP echo handlers consume requests

Overview

This change makes ICMP Echo Request handling follow the usual SetTransportProtocolHandler default-handler contract: if an ICMP default handler returns true, the stack treats the request as consumed and does not also synthesize a built-in Echo Reply.

The default behavior is preserved when there is no ICMP default handler, or when the handler returns false.

The intended scope is small:

Related discussion:

Context

TCP and UDP default handlers already have a clear ownership signal:

defaultHandler returns true
        |
        v
packet is handled by the embedder
protocol fallback does not continue

ICMP Echo is different today because Echo Requests are intercepted by IPv4/IPv6 network endpoints before the built-in reply is generated.

That makes it possible for an embedder to receive an Echo Request and still get a second reply from netstack.

The earlier discussion started in #8657, where @fredwangwang reported that ICMP Echo could still be answered by the stack even when an ICMP handler was installed with SetTransportProtocolHandler. @deepcode2019 noted the same need, and @kevinGC suggested that a tested PR would be welcome.

#11609 took a narrow first step for IPv4 promiscuous-mode temporary addresses. After it merged, @ericpauley pointed out that restoring the old behavior could require reimplementing ICMP in a custom handler, and @dyhkwong noted the remaining IPv4/IPv6 inconsistency.

This change addresses that follow-up without widening the behavior change beyond Echo Request default-handler ownership.

The compatibility-sensitive case is runsc. It registers ICMP as a normal transport protocol, but does not install an ICMP default handler for this path:

runsc/boot/loader.go

transProtos := []stack.TransportProtocolFactory{
    tcp.NewProtocol,
    udp.NewProtocol,
    icmp.NewProtocol4,
    icmp.NewProtocol6,
}

So the no-default-handler path remains the ordinary built-in Echo Reply path.

Why This Matters

The main beneficiary is not ICMP itself, but embedders that use netstack inside user-space tunneling or virtual-networking software.

Several widely used downstream or adjacent projects use netstack, or downstream forks of it, in this role, including Tailscale's userspace networking, wireguard-go's netstack TUN backend, sing-box/mihomo-style TUN stacks, and tun2socks.

For those programs, an Echo Reply is often a policy decision:

Echo Request
  -> should this address be reachable through the tunnel?
  -> should the request be forwarded to a remote peer?
  -> should the reply reflect tunnel reachability, latency, filtering, or policy?

If netstack synthesizes a local Echo Reply after the embedder has consumed the request, unreachable or policy-blocked addresses can appear reachable.

After this change, ICMP default handlers can return true and take responsibility for Echo behavior. That lets tunneling software return replies that reflect its own forwarding policy and the current state of the tunnel.

Flow

Current behavior:

IPv4 Echo Request
  -> network/ipv4 ICMP handler
  -> DeliverTransportPacket(ICMPv4, pkt)
  -> if LocalAddressTemporary: stop
  -> built-in Echo Reply

IPv6 Echo Request
  -> network/ipv6 ICMP handler
  -> built-in Echo Reply

New behavior:

ICMP Echo Request
  -> network ICMP handler
  -> deliver to ICMP endpoint/defaultHandler
       |-- defaultHandler returned true  -> stop
       `-- otherwise                     -> continue built-in handling

built-in handling
  -> (IPv4 only: if LocalAddressTemporary -> stop)
  -> synthesize Echo Reply

The asymmetry above is intentional: the new default-handler ownership rule is shared by IPv4 and IPv6, while LocalAddressTemporary is a pre-existing IPv4 compatibility guard from #11609.

Implementation

Relevant code points:

TransportPacketHandled is too broad for this decision:

TransportPacketHandled
  |-- registered endpoint matched
  |-- defaultHandler returned true
  |-- unknown-destination handler consumed it
  `-- malformed packet was swallowed

Only one branch should suppress the built-in Echo Reply:

defaultHandler returned true

This change therefore adds a narrow dispatcher extension:

type TransportDispatcherWithDefaultHandlerResult interface {
    TransportDispatcher

    DeliverTransportPacketWithDefaultHandlerResult(
        tcpip.TransportProtocolNumber,
        *PacketBuffer,
    ) (TransportPacketDisposition, bool)
}

The second result is true only when the per-stack defaultHandler returned true:

if n.stack.demux.deliverPacket(protocol, pkt, id) {
    return TransportPacketHandled, false
}

if state.defaultHandler != nil {
    if state.defaultHandler(id, pkt) {
        return TransportPacketHandled, true
    }
}

This keeps endpoint visibility separate from default-handler ownership.

For IPv6, the built-in reply path now saves the data needed for the reply before transport delivery, because DeliverTransportPacket may modify the PacketBuffer:

replyPayload := pkt.Data().ToBuffer()
replyHeader := make([]byte, header.ICMPv6EchoMinimumSize)
copy(replyHeader, h[:header.ICMPv6EchoMinimumSize])

This also makes IPv6 Echo Requests observable through the same registered-endpoint/default-handler delivery path used by IPv4. A registered ICMP endpoint does not suppress the built-in reply; only a per-stack default handler returning true does.

Compatibility

Default runsc/netstack behavior should remain unchanged.

This change separates two decisions:

defaultHandler returned true
  -> new shared IPv4/IPv6 ownership rule

IPv4 LocalAddressTemporary
  -> existing #11609 guard, preserved as-is

Behavior matrix:

no ICMP default handler        -> built-in Echo Reply
default handler returns false  -> built-in Echo Reply
default handler returns true   -> consumed by handler; no built-in Echo Reply
IPv4 LocalAddressTemporary     -> preserve #11609; no built-in Echo Reply

ICMPv6 scope:

changed:    Echo Request
unchanged:  NDP, Packet Too Big, Destination Unreachable, Time Exceeded, other control paths

I am not trying to make IPv6 mirror the IPv4 LocalAddressTemporary path in this change. That would be a separate behavior change. Here, IPv6 gains the Echo Request endpoint/default-handler delivery path and the default-handler ownership rule.

LocalAddressTemporary remains useful as an IPv4 signal for embedders that already need to distinguish promiscuous-mode temporary local delivery from ordinary local delivery.

Follow-ups

I plan to keep the remaining work split into smaller follow-ups.

If this direction looks acceptable, I can follow up promptly with the mechanical helper extraction first. The ICMP Forwarder API can then be discussed on top of that smaller refactor, so the public API shape does not have to be decided in this change.

First, factor the built-in IPv4/IPv6 Echo Reply construction into an internal helper while preserving route lookup, rate limiting, stats, checksums, IPv4 options, IPv6 traffic class handling, and output hooks.

Then add an ICMP Forwarder API, analogous to the TCP/UDP forwarders:

f := icmp.NewForwarder(s, func(r *icmp.ForwarderRequest) bool {
    if shouldHandleByTunnel(r) {
        return handleByTunnel(r)
    }
    return r.Reply()
})

s.SetTransportProtocolHandler(icmp.ProtocolNumber4, f.HandlePacket)
s.SetTransportProtocolHandler(icmp.ProtocolNumber6, f.HandlePacket)

The goal of ForwarderRequest.Reply() is to let embedders explicitly delegate Echo Reply generation back to the stack, without copying netstack's reply construction logic downstream. That should also provide a cleaner way to restore the old "let netstack reply" behavior when a custom handler decides not to own a particular Echo Request.

Tests

The new tests cover the behavior matrix directly.

IPv4:

TestICMPEchoDefaultHandlerControlsReply
  no default handler             -> built-in Echo Reply is preserved
  default handler returns false  -> built-in Echo Reply is preserved
  default handler returns true   -> no built-in Echo Reply

TestICMPEchoRegisteredEndpointDoesNotSuppressReply
  registered endpoint is matched -> default handler is not called
                                  -> built-in Echo Reply is preserved

TestICMPEchoTemporaryAddressSuppressesReply
  promiscuous temporary address  -> built-in Echo Reply is suppressed

IPv6:

TestICMPEchoDefaultHandlerControlsReply
  no default handler             -> built-in Echo Reply is preserved
  default handler returns false  -> built-in Echo Reply is preserved
  default handler returns true   -> no built-in Echo Reply

TestICMPEchoRegisteredEndpointDoesNotSuppressReply
  registered endpoint is matched -> default handler is not called
                                  -> built-in Echo Reply is preserved

Commands:

make test TARGETS=//pkg/tcpip/network/ipv4:ipv4_test OPTIONS=--test_filter=TestICMPEcho
make test TARGETS=//pkg/tcpip/network/ipv6:ipv6_test OPTIONS=--test_filter=TestICMPEcho
make test TARGETS=//pkg/tcpip/network/ipv4:ipv4_test OPTIONS=--test_filter=TestIcmpRateLimit
make test TARGETS=//pkg/tcpip/network/ipv6:ipv6_test OPTIONS=--test_filter=TestNeighborSolicitationResponse
make test TARGETS="//pkg/tcpip/network/ipv4:ipv4_test //pkg/tcpip/network/ipv6:ipv6_test"
make test TARGETS=//pkg/tcpip/stack:stack_test

All of the commands above pass in my fork branch, netstack-icmp-echo-default-handler.

Review Context

While preparing the change, I discussed the direction with several downstream/heavy netstack users to validate the embedding use case and compatibility expectations.

I also used AI-assisted review as an additional pass to look for missing edge cases. The technical argument here is still based on the source links, behavior matrix, and tests above.

Fixes #8657.

FUTURE_COPYBARA_INTEGRATE_REVIEW=#13189 from Amaindex:netstack-icmp-echo-default-handler 8b0e162

IPv4 echo requests are delivered to the transport dispatcher before netstack
builds the in-stack echo reply, but a per-stack default handler cannot tell
the ICMP endpoint that it has taken ownership of the request. This leaves
embedders that install a custom ICMP handler with no direct way to suppress
the built-in reply.

Add a transport dispatcher extension that reports whether the per-stack
default transport handler consumed the packet, and use that signal in the
IPv4 and IPv6 ICMP echo paths. For IPv6, deliver echo requests through the
same endpoint/default-handler path before deciding whether to synthesize the
built-in reply.

Keep ordinary endpoint delivery distinct from default-handler ownership so
registered ICMP endpoints and raw sockets can observe echo requests without
suppressing the normal reply path.

This also preserves the IPv4 temporary-address behavior introduced by the
earlier echo-handling fix.

Signed-off-by: Zi Li <zi.li@linux.dev>
Signed-off-by: Amaindex <amaindex@outlook.com>
@copybara-service copybara-service Bot added the exported Issue was exported automatically label May 21, 2026
@copybara-service copybara-service Bot force-pushed the test/cl919087849 branch 2 times, most recently from 39b150a to ecd253a Compare May 21, 2026 18:33
@copybara-service copybara-service Bot merged commit b8389ea into master May 21, 2026
0 of 2 checks passed
@copybara-service copybara-service Bot deleted the test/cl919087849 branch May 21, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

exported Issue was exported automatically

Projects

None yet

Development

Successfully merging this pull request may close these issues.

allow custom icmp handler to respond icmp packets

2 participants