internal/agent/netsnoop/netsnoop_test.go
Ref: Size: 7.3 KiB History
package netsnoop
import (
"bytes"
"net"
"testing"
"github.com/insomniacslk/dhcp/dhcpv4"
)
// frame wraps a DHCP payload in Ethernet/IPv4/UDP headers the way it crosses
// a guest tap: server 67 -> client 68.
func frame(t *testing.T, payload []byte) []byte {
t.Helper()
return framePorts(t, payload, 67, 68)
}
// framePorts is frame with the UDP ports under the test's control, so the
// port guard can be exercised without hand-rolling a second builder.
func framePorts(t *testing.T, payload []byte, src, dst int) []byte {
t.Helper()
udp := make([]byte, 8+len(payload))
udp[0], udp[1] = byte(src>>8), byte(src)
udp[2], udp[3] = byte(dst>>8), byte(dst)
udp[4], udp[5] = byte(len(udp)>>8), byte(len(udp))
copy(udp[8:], payload)
ip := make([]byte, 20+len(udp))
ip[0] = 0x45 // v4, ihl 5
ip[2], ip[3] = byte(len(ip)>>8), byte(len(ip))
ip[8] = 64
ip[9] = 17 // UDP
copy(ip[20:], udp)
eth := make([]byte, 14+len(ip))
eth[12], eth[13] = 0x08, 0x00 // IPv4
copy(eth[14:], ip)
return eth
}
// newReply builds a server->client DHCP message. dhcpv4.New defaults the
// opcode to BootRequest (the client direction), so replies set it explicitly.
func newReply(t *testing.T, mods ...dhcpv4.Modifier) *dhcpv4.DHCPv4 {
t.Helper()
msg, err := dhcpv4.New(mods...)
if err != nil {
t.Fatal(err)
}
msg.OpCode = dhcpv4.OpcodeBootReply
return msg
}
// reply builds a server->client DHCP message of the given type for mac,
// granting yiaddr ("" leaves yiaddr unset).
func reply(t *testing.T, mac net.HardwareAddr, typ dhcpv4.MessageType, yiaddr string) []byte {
t.Helper()
mods := []dhcpv4.Modifier{
dhcpv4.WithMessageType(typ),
dhcpv4.WithHwAddr(mac),
}
if yiaddr != "" {
mods = append(mods, dhcpv4.WithYourIP(net.ParseIP(yiaddr)))
}
return newReply(t, mods...).ToBytes()
}
// withIPOptions splices four bytes of IPv4 option (NOP, NOP, NOP, EOL) into a
// frame's IP header and fixes up IHL and total length, so that the UDP header
// starts at 4*IHL rather than at a fixed 20. It catches anyone reading the
// ports at a constant offset — in the parser or in the kernel filter.
func withIPOptions(t *testing.T, plain []byte) []byte {
t.Helper()
out := make([]byte, 0, len(plain)+4)
out = append(out, plain[:14+20]...)
out = append(out, 1, 1, 1, 0)
out = append(out, plain[14+20:]...)
out[14] = 0x46 // v4, ihl 6
total := len(out) - 14
out[16], out[17] = byte(total>>8), byte(total)
return out
}
// granted is one frame that carries a real lease, with what must be read out
// of it.
type granted struct {
frame []byte
mac net.HardwareAddr
ip string
}
// grantedFrames is the corpus of real leases: every shape a site's server
// grants one in that has to survive the receive path intact. Both halves of
// that path answer to it — ParseACK must report each of these, and the kernel
// filter must pass each of them — because the filter runs first, so a frame it
// drops is one the parser never sees.
func grantedFrames(t *testing.T) map[string]granted {
t.Helper()
first, _ := net.ParseMAC("52:54:00:aa:bb:cc")
renewing, _ := net.ParseMAC("52:54:00:de:ad:01")
plain := frame(t, reply(t, first, dhcpv4.MessageTypeAck, "192.168.0.42"))
// A renewal is unicast to the guest and carries the options a real server
// sends a lease with, after the fixed header. Renewals are the only chance
// to notice a site server moving a guest, so they have to be read exactly
// like the broadcast first lease.
renewal := newReply(t,
dhcpv4.WithMessageType(dhcpv4.MessageTypeAck),
dhcpv4.WithHwAddr(renewing),
dhcpv4.WithYourIP(net.ParseIP("10.4.5.6")),
dhcpv4.WithNetmask(net.CIDRMask(24, 32)),
dhcpv4.WithRouter(net.ParseIP("10.4.5.1")),
dhcpv4.WithDNS(net.ParseIP("10.4.5.1")),
)
return map[string]granted{
"first lease": {plain, first, "192.168.0.42"},
"renewal with options": {frame(t, renewal.ToBytes()), renewing, "10.4.5.6"},
"IP header with options": {withIPOptions(t, plain), first, "192.168.0.42"},
}
}
func TestParseACKAcceptsEveryGrantedLease(t *testing.T) {
for name, want := range grantedFrames(t) {
gotMAC, gotIP, ok := ParseACK(want.frame)
if !ok || !bytes.Equal(gotMAC, want.mac) || gotIP.String() != want.ip {
t.Errorf("%s: ParseACK = %v %v %v, want %v %s true",
name, gotMAC, gotIP, ok, want.mac, want.ip)
}
}
}
func TestParseACKRejects(t *testing.T) {
mac, _ := net.ParseMAC("52:54:00:aa:bb:cc")
ack := reply(t, mac, dhcpv4.MessageTypeAck, "192.168.0.42")
// A request the guest sends: client -> server, and a BootRequest (New's
// default opcode).
req, err := dhcpv4.New(
dhcpv4.WithMessageType(dhcpv4.MessageTypeRequest),
dhcpv4.WithHwAddr(mac),
)
if err != nil {
t.Fatal(err)
}
truncatedIP := frame(t, ack)[:14+12]
badIHL := frame(t, ack)
badIHL[14] = 0x44 // v4, ihl 4 — shorter than the fixed header allows
notUDP := frame(t, ack)
notUDP[14+9] = 6 // TCP
// A guest that forges an ACK on the right ports still fails the opcode
// check: only a boot reply grants an address.
forged := newReply(t,
dhcpv4.WithMessageType(dhcpv4.MessageTypeAck),
dhcpv4.WithHwAddr(mac),
dhcpv4.WithYourIP(net.ParseIP("192.168.0.42")),
)
forged.OpCode = dhcpv4.OpcodeBootRequest
for name, b := range map[string][]byte{
"offer-not-ack": frame(t, reply(t, mac, dhcpv4.MessageTypeOffer, "192.168.0.42")),
"nak-not-ack": frame(t, reply(t, mac, dhcpv4.MessageTypeNak, "")),
"guest-request": framePorts(t, req.ToBytes(), 68, 67),
"opcode-request": frame(t, forged.ToBytes()),
"ack-wrong-dst": framePorts(t, ack, 67, 12345),
"ack-wrong-src": framePorts(t, ack, 12345, 68),
"zero-yiaddr": frame(t, reply(t, mac, dhcpv4.MessageTypeAck, "")),
"truncated-ip": truncatedIP,
"bad-ihl": badIHL,
"not-udp": notUDP,
"garbage-dhcp": frame(t, []byte{1, 2, 3, 4}),
"short": {0, 1, 2},
"not-ipv4": append(make([]byte, 14), 0xde),
"arp-ethertype": append([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x08, 0x06}, make([]byte, 60)...),
"ipv6-in-v4-eth": func() []byte { f := frame(t, ack); f[14] = 0x65; return f }(),
"empty": {},
"nil": nil,
} {
if _, _, ok := ParseACK(b); ok {
t.Errorf("%s: ParseACK accepted, want reject", name)
}
}
}
// A DHCPv4 yiaddr is four bytes on the wire, so anything ParseACK decodes
// has a non-nil To4 and the To4==nil arm of the address guard is unreachable
// from a real frame. This pins that invariant: if the decoder ever started
// handing back a wider IP, the guard would become live and this test would be
// the record of why it exists.
func TestDecodedYourIPIsAlwaysV4(t *testing.T) {
mac, _ := net.ParseMAC("52:54:00:aa:bb:cc")
msg, err := dhcpv4.FromBytes(reply(t, mac, dhcpv4.MessageTypeAck, "192.168.0.42"))
if err != nil {
t.Fatal(err)
}
if msg.YourIPAddr.To4() == nil {
t.Fatalf("decoded yiaddr %v is not IPv4-shaped", msg.YourIPAddr)
}
}
// The snoop runs on every frame a busy tap carries, so it must survive
// arbitrary bytes without panicking — the only failure mode allowed is ok=false.
func TestParseACKFuzzResistant(t *testing.T) {
mac, _ := net.ParseMAC("52:54:00:aa:bb:cc")
full := frame(t, reply(t, mac, dhcpv4.MessageTypeAck, "192.168.0.42"))
for i := range full {
ParseACK(full[:i]) // every truncation of a real frame
}
for i := range full {
mutated := append([]byte(nil), full...)
mutated[i] ^= 0xff
ParseACK(mutated)
}
}