netstack: add ICMP echo forwarder reply API#13413
Conversation
Add an ICMP forwarder adapter that lets embedders install a per-stack ICMP handler through SetTransportProtocolHandler and explicitly delegate selected Echo Requests back to netstack with ForwarderRequest.Reply(). Keep the existing handler ownership contract unchanged: returning true consumes the packet, returning false leaves fallback behavior available, and Reply is a separate request to synthesize the built-in Echo Reply. The built-in reply construction remains in the IPv4 and IPv6 network endpoint paths so transport/icmp does not duplicate route lookup, source address selection, rate limiting, stats, checksum handling, IPv4 options, IPv6 traffic class handling, or output hooks. Preserve the conservative default behavior for IPv4 LocalAddressTemporary addresses while allowing a handler to opt in to the built-in reply path for selected requests. Reply uses the original Echo Request destination as the reply source in that explicit path. The per-packet reply hook is transient, is cleared after transport delivery, is invalidated when the forwarder handler returns, and is not copied by PacketBuffer.Clone(). Signed-off-by: Zi Li <zi.li@linux.dev> Signed-off-by: Amaindex <amaindex@outlook.com>
cf6051b to
dc1b84e
Compare
|
Thanks for the earlier reviews and discussion on this series. With the default handler behavior from #13189 and #13281 in place, this follow-up is the piece that makes the ICMP Echo path easier to use from embedder code. I wanted to leave a few concrete usage notes here, because the interesting part of this API is how an embedder chooses between letting netstack answer an Echo Request and taking ownership of the request itself. cc @nybidari, @ericpauley, @dyhkwong The main point is that embedders no longer need to reimplement gVisor's built-in ICMP Echo Reply construction just to let netstack answer a request in selected cases. A handler can still own the request when it wants to proxy/drop/forward it, but it can now explicitly delegate the built-in reply back to netstack. Some typical handler shapes are: Tunnel/proxy owns the request and suppresses the built-in reply: f := icmp.NewForwarder(func(r *icmp.ForwarderRequest) bool {
if proxy.SendICMPEcho(r) == nil {
return true
}
return false
})Inside that helper, the proxy should copy the request fields it needs before returning from the handler. For example: type proxyICMPEchoRequest struct {
nic tcpip.NICID
netProto tcpip.NetworkProtocolNumber
source tcpip.Address
destination tcpip.Address
echo []byte // ICMP Echo header plus payload.
}
func (p *Proxy) SendICMPEcho(r *icmp.ForwarderRequest) error {
pkt := r.PacketBuffer()
id := r.ID()
echo := stack.PayloadSince(pkt.TransportHeader())
defer echo.Release()
req := proxyICMPEchoRequest{
nic: pkt.NICID,
netProto: pkt.NetworkProtocolNumber,
source: id.RemoteAddress,
destination: id.LocalAddress,
echo: echo.ToSlice(),
}
go p.forwardICMPEcho(req)
return nil
}
If the proxy later wants to report measured upstream/proxy latency, it should write a synthetic Echo Reply itself. I also ran a small TUN smoke test to exercise that path end to end. Proxy-owned synthetic Echo Reply smoke testThe host-side setup was: sudo ip tuntap del dev gvicmp0 mode tun 2>/dev/null || true
sudo ip tuntap add dev gvicmp0 mode tun
sudo ip addr add 11.0.0.1/24 dev gvicmp0
sudo ip link set gvicmp0 upThe test program configured the netstack address as Terminal 1: sudo ./icmp_proxy_tun -tun=gvicmp0 -addr=11.0.0.2 -delay=4242msTerminal 2: ping -n -i 5 11.0.0.2Observed from the host: Harness logs: The route-based writer in that test looked like this: Route-based synthetic Echo Reply writerfunc (p *Proxy) writeSyntheticEchoReply(req proxyICMPEchoRequest) tcpip.Error {
r, err := p.stack.FindRoute(req.nic, req.destination, req.source, req.netProto, false /* multicastLoop */)
if err != nil {
return err
}
defer r.Release()
switch req.netProto {
case ipv4.ProtocolNumber:
if len(req.echo) < header.ICMPv4MinimumSize {
return &tcpip.ErrInvalidEndpointState{}
}
echo := append([]byte(nil), req.echo...)
icmp := header.ICMPv4(echo)
icmp.SetType(header.ICMPv4EchoReply)
icmp.SetChecksum(0)
icmp.SetChecksum(^checksum.Checksum(echo, 0))
pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
ReserveHeaderBytes: int(r.MaxHeaderLength()) + header.ICMPv4MinimumSize,
Payload: buffer.MakeWithData(echo[header.ICMPv4MinimumSize:]),
})
defer pkt.DecRef()
pkt.TransportProtocolNumber = header.ICMPv4ProtocolNumber
copy(header.ICMPv4(pkt.TransportHeader().Push(header.ICMPv4MinimumSize)), echo[:header.ICMPv4MinimumSize])
return r.WritePacket(stack.NetworkHeaderParams{
Protocol: header.ICMPv4ProtocolNumber,
TTL: r.DefaultTTL(),
TOS: stack.DefaultTOS,
}, pkt)
case ipv6.ProtocolNumber:
if len(req.echo) < header.ICMPv6EchoMinimumSize {
return &tcpip.ErrInvalidEndpointState{}
}
echo := append([]byte(nil), req.echo...)
payload := echo[header.ICMPv6EchoMinimumSize:]
pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
ReserveHeaderBytes: int(r.MaxHeaderLength()) + header.ICMPv6EchoMinimumSize,
Payload: buffer.MakeWithData(payload),
})
defer pkt.DecRef()
icmp := header.ICMPv6(pkt.TransportHeader().Push(header.ICMPv6EchoMinimumSize))
pkt.TransportProtocolNumber = header.ICMPv6ProtocolNumber
copy(icmp, echo[:header.ICMPv6EchoMinimumSize])
icmp.SetType(header.ICMPv6EchoReply)
icmp.SetChecksum(0)
icmp.SetChecksum(header.ICMPv6Checksum(header.ICMPv6ChecksumParams{
Header: icmp,
Src: r.LocalAddress(),
Dst: r.RemoteAddress(),
PayloadCsum: pkt.Data().Checksum(),
PayloadLen: pkt.Data().Size(),
}))
return r.WritePacket(stack.NetworkHeaderParams{
Protocol: header.ICMPv6ProtocolNumber,
TTL: r.DefaultTTL(),
TOS: stack.DefaultTOS,
}, pkt)
}
return &tcpip.ErrUnknownProtocol{}
}This manual path is intentionally different from Large-address-space / temporary-address embedder delegates selected requests back to netstack: f := icmp.NewForwarder(func(r *icmp.ForwarderRequest) bool {
if r.PacketInfo().LocalAddressTemporary {
return r.Reply()
}
return false
})Observer-style handler records metadata but leaves fallback behavior unchanged: f := icmp.NewForwarder(func(r *icmp.ForwarderRequest) bool {
observe(r.ID(), r.PacketInfo())
return false
})Dual-stack embedders can install the same handler shape for IPv4 and IPv6: f := icmp.NewForwarder(handleICMPEcho)
s.SetTransportProtocolHandler(icmp.ProtocolNumber4, f.HandlePacket)
s.SetTransportProtocolHandler(icmp.ProtocolNumber6, f.HandlePacket)For the large-address-space / temporary-address case, For tunnel/proxy-style handlers, the ownership rule remains explicit: returning I kept this as |
|
Thanks for this excellent pull request and synopsis (and particularly for taking our use case feedback into account). I believe the "Large-address-space / temporary-address embedder" scenario will fully cover our use case. |
Overview
This is the next ICMP Echo follow-up after #13189 and #13281.
It adds an
icmp.Forwarderadapter for ICMP packets delivered throughstack.SetTransportProtocolHandler, and lets the handler explicitly delegate ICMP Echo Reply generation back to netstack withForwarderRequest.Reply().The goal is to let embedders handle ICMP Echo Requests without reimplementing the built-in reply construction logic downstream.
Behavior
The default behavior remains conservative:
The new explicit delegation path is:
Reply()is idempotent. If a handler callsReply()and then returnsfalse, netstack does not synthesize a second built-in Echo Reply on the fallback path. A successfulReply()return means the built-in reply path was available and accepted for this request; it is not a packet-delivery guarantee.API Semantics
This API keeps the existing
SetTransportProtocolHandlerownership contract:ForwarderRequest.Reply()is a separate explicit action. It asks netstack to synthesize the built-in Echo Reply for this request, without changing what the handler's return value means.That gives handlers two independent decisions:
I kept the final API as
bool + Reply()because it fits the existing default-handler contract and keeps the user-facing surface small. An action enum could model this, but it would need to combine two distinct decisions: whether the handler consumed the packet, and whether it asked netstack to send the built-in Echo Reply. KeepingReply()as an explicit request method avoids redefiningSetTransportProtocolHandlersemantics for ICMP.LocalAddressTemporary
This change keeps the no-handler IPv4
LocalAddressTemporarybehavior from #11609 and #13189: temporary local addresses do not automatically trigger a built-in Echo Reply.The new capability is explicit delegation. If the ICMP default handler sees
r.PacketInfo().LocalAddressTemporaryand decides that netstack should still generate the Echo Reply, it can callr.Reply(). In that path, the reply uses the original Echo Request destination address as the reply source, and the reply path can find a route without requiring that temporary address to be registered as the route local address.That addresses the large-address-space embedder case discussed in #13189 without changing the conservative default for tunnel/proxy handlers that intentionally consume Echo Requests.
This does not add an IPv6
Temporary()-based Echo Reply suppression rule.Implementation Notes
The user-facing adapter is intentionally small:
icmp.NewForwarder(handler)constructs a handler adapter.Forwarder.HandlePacketcan be passed tostack.SetTransportProtocolHandler.ForwarderRequest.ID()exposes the parsed ICMP identifier throughstack.TransportEndpointID.ForwarderRequest.PacketBuffer()exposes the packet for the duration of the handler call.ForwarderRequest.PacketInfo()exposes network-layer metadata such asLocalAddressTemporary.ForwarderRequest.Reply()delegates to the built-in Echo Reply path.The actual Echo Reply construction remains in the IPv4/IPv6 network endpoint layer.
transport/icmpdoes not duplicate route lookup, source address selection, rate limiting, stats, checksum handling, IPv4 options, IPv6 traffic class, or output hooks.The per-packet reply hook is not the intended embedder-facing API. Embedders should use
ForwarderRequest.Reply(); the exportedPacketBufferhook helpers are a cross-package bridge that lets the network endpoint path expose its built-in reply operation to the ICMP forwarder without moving protocol-specific reply construction intotransport/icmp.The hook is deliberately scoped to packet delivery:
PacketBufferstores a private transient hook object.NetworkPacketInforemains metadata-only.ForwarderRequestis invalidated after the handler returns.PacketBuffer.Clone()does not copy the reply hook.This keeps the hook from escaping past the temporary reply buffers prepared by the network endpoint.
Tests
The added tests cover:
Reply()delegation.Reply()idempotence when a handler returnsfalseafter replying.Forwarderconsuming an Echo Request withoutReply().Reply()returningfalsefor non-Echo-Request packets.PacketBufferinvalidation after handler return.PacketBuffernot inheriting the reply hook.Reply()override.go run github.com/bazelbuild/bazelisk@latest test --nocache_test_results \ //pkg/tcpip/network/ipv4:ipv4_test \ //pkg/tcpip/network/ipv6:ipv6_test \ //pkg/tcpip/network/ipv6:ipv6_x_test \ //pkg/tcpip/transport/icmp:icmp_x_test \ //pkg/tcpip/transport/icmp:icmp_nogo \ //pkg/tcpip/stack:stack_test \ //pkg/tcpip/stack:stack_x_test \ //pkg/tcpip/stack:stack_nogoRelated