Files
newt/netstack2/icmp_dnat_test.go
2026-08-24 11:58:41 -04:00

153 lines
4.8 KiB
Go

package netstack2
import (
"bytes"
"io"
"net/netip"
"os"
"strings"
"testing"
"time"
"github.com/fosrl/newt/logger"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/checksum"
"gvisor.dev/gvisor/pkg/tcpip/header"
)
// buildICMPEchoRequest builds a minimal, checksummed IPv4 ICMP echo request
// packet from src to dst.
func buildICMPEchoRequest(t *testing.T, src, dst netip.Addr) []byte {
t.Helper()
const icmpSize = header.ICMPv4MinimumSize
totalLen := header.IPv4MinimumSize + icmpSize
pkt := make([]byte, totalLen)
ip := header.IPv4(pkt)
ip.Encode(&header.IPv4Fields{
TotalLength: uint16(totalLen),
TTL: 64,
Protocol: uint8(header.ICMPv4ProtocolNumber),
SrcAddr: tcpip.AddrFrom4(src.As4()),
DstAddr: tcpip.AddrFrom4(dst.As4()),
})
ip.SetChecksum(0)
ip.SetChecksum(^ip.CalculateChecksum())
icmp := header.ICMPv4(pkt[header.IPv4MinimumSize:])
icmp.SetType(header.ICMPv4Echo)
icmp.SetCode(0)
icmp.SetIdent(1)
icmp.SetSequence(1)
icmp.SetChecksum(0)
icmp.SetChecksum(header.ICMPv4Checksum(icmp, checksum.Checksum(icmp.Payload(), 0)))
return pkt
}
// noopNotification is a no-op channel.Notification for tests that don't
// care about read-availability notifications.
type noopNotification struct{}
func (noopNotification) WriteNotify() {}
// captureLogOutput redirects the package logger to a pipe for the duration
// of fn, and returns everything written to it. Needed here because
// ICMPHandler.handleICMPPacket runs on its own goroutine off of
// HandleIncomingPacket and reports its outcome only via log lines.
func captureLogOutput(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
logger.SetOutput(w)
defer logger.SetOutput(os.Stdout)
done := make(chan string, 1)
go func() {
var buf bytes.Buffer
io.Copy(&buf, r)
done <- buf.String()
}()
fn()
// handleICMPPacket runs asynchronously (go h.proxyPing(...)); give it a
// moment to log its outcome before we stop capturing.
time.Sleep(200 * time.Millisecond)
w.Close()
return <-done
}
// A DNAT target whose RewriteTo isn't independently reachable as its own
// destination (e.g. a resolved domain name, or - as here - just an IP with
// no mirrored direct subnet rule) used to make ICMP echo requests get
// silently dropped: the old ICMPHandler re-derived the DNAT target by
// running a fresh SubnetLookup.Match() against whatever address the packet
// carried by the time it reached the handler, which for a non-loopback
// rewrite is already the post-DNAT address - and no rule exists for that
// address on its own. The fix resolves the real target via
// destRewriteTable (LookupDestinationRewrite) instead, exactly like the
// TCP/UDP handlers already did, so this no longer depends on a mirrored
// direct rule existing for the rewritten address.
func TestICMPHandleIncomingPacket_DNATWithoutMirroredDirectRule(t *testing.T) {
ph, err := NewProxyHandler(ProxyHandlerOptions{EnableICMP: true, MTU: 1500})
if err != nil {
t.Fatalf("NewProxyHandler: %v", err)
}
if err := ph.Initialize(noopNotification{}); err != nil {
t.Fatalf("Initialize: %v", err)
}
defer ph.Close()
srcAddr := netip.MustParseAddr("10.0.0.5")
aliasAddr := netip.MustParseAddr("10.20.20.9")
realAddr := netip.MustParseAddr("203.0.113.50")
ph.AddSubnetRule(SubnetRule{
SourcePrefix: netip.MustParsePrefix("10.0.0.0/24"),
DestPrefix: netip.PrefixFrom(aliasAddr, 32),
RewriteTo: realAddr.String() + "/32",
})
pkt := buildICMPEchoRequest(t, srcAddr, aliasAddr)
var injected bool
logs := captureLogOutput(t, func() {
injected = ph.HandleIncomingPacket(pkt)
})
if !injected {
t.Fatal("expected ICMP echo request to be matched and injected")
}
// destRewriteTable is populated by HandleIncomingPacket itself, so this
// much holds regardless of the bug - included here to pin the mechanism
// the fix relies on.
got, ok := ph.LookupDestinationRewrite(srcAddr.String(), aliasAddr.String(), 0, uint8(header.ICMPv4ProtocolNumber))
if !ok {
t.Fatal("expected destRewriteTable to have an entry for this ICMP flow")
}
if got != realAddr {
t.Fatalf("expected rewritten destination %s, got %s", realAddr, got)
}
// This is the actual regression check: with the bug, handleICMPPacket
// re-matches on the already-rewritten address, finds no rule for it,
// and drops the echo request before ever attempting to proxy it.
if strings.Contains(logs, "No matching subnet rule") {
t.Errorf("ICMP handler dropped the echo request instead of proxying it; logs:\n%s", logs)
}
if !strings.Contains(logs, "Proxying ping from") {
t.Errorf("expected ICMP handler to proxy the ping to the rewritten destination; logs:\n%s", logs)
}
if !strings.Contains(logs, realAddr.String()) {
t.Errorf("expected logs to reference the rewritten destination %s; logs:\n%s", realAddr, logs)
}
}