NodeConfig defaults + code-quality pass + fuzz tests + README

NodeConfig.Spec.Defaults adds per-node IPv6/IPv4 family defaults that pod
annotations can override; built-in baseline (v6=true, v4=false) still
applies when the field is omitted.

bird.Render now validates every operator-supplied value (peer addresses,
CIDRs, anycast IPs, source addresses) before templating — fuzz found a
peer address containing `}` produced unbalanced braces in bird.conf.
Failing input preserved as a regression seed.

Fuzz targets added for ParseAnnotations, ParseCNIArgs, HostIfaceName,
canonical, IPAM allocate sequences, embed.Embed, and bird.Render.
Hardened canonical/ipToU32 against nil and non-IPv4 inputs.

README rewritten for outside readers — quickstart, NodeConfig + annotation
reference with worked examples, anycast use cases, comparison vs Calico
and Cilium, requirements, limitations.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Donavan Fritz
2026-04-25 09:25:45 -05:00
parent 677aec2a42
commit 71e584cf96
17 changed files with 1583 additions and 100 deletions
+169
View File
@@ -0,0 +1,169 @@
package agent
import (
"net"
"testing"
)
// FuzzIPAM_Allocate runs randomly-driven Allocate/Release sequences against
// a /120 IPv6 + /28 IPv4 IPAM so the fuzzer can hit address exhaustion.
//
// Properties checked:
//
// 1. Allocate never panics regardless of the action stream.
// 2. The set of in-use addresses never contains an address that has been
// released without a subsequent successful Allocate.
// 3. A successful v6 allocation always yields an address inside the
// configured /120, and a successful v4 always inside the configured /28.
// 4. ipToU32(canonical(allocated v4)) round-trips, and likewise that no
// v4 allocation lands on .0 (network) or .15 (broadcast) of the /28.
//
// The fuzzed bytes are interpreted as an opcode stream:
// - bytes[i] & 0x03 selects the action: 0=alloc-v6, 1=alloc-v4,
// 2=alloc-dual, 3=release-most-recent.
// - bytes[i]>>2 is fed into the deterministic random source so different
// fuzzed bytes drive different IID/index choices.
func FuzzIPAM_Allocate(f *testing.F) {
f.Add([]byte{0, 0, 0, 0})
f.Add([]byte{1, 1, 1, 1})
f.Add([]byte{2, 2, 2, 2})
f.Add([]byte{0, 1, 2, 3})
f.Add([]byte(longString("\x00\x01\x02\x03", 256)))
f.Fuzz(func(t *testing.T, ops []byte) {
ipam, err := NewIPAM(
[]string{"2001:db8::/120"}, // 256 host slots; 16 bytes of fuzzed nibbles
[]string{"10.0.0.0/28"}, // 14 usable hosts (.2..14)
)
if err != nil {
t.Fatal(err)
}
// Deterministic source: replay nibbles cycled from `ops`.
fr := &fakeRand{
nibbles: append([]byte{}, ops...),
iids: [][]byte{
// 16 bytes of "host portion" — only the last byte matters
// for a /120 prefix.
makeIID(ops, 0),
makeIID(ops, 1),
makeIID(ops, 2),
makeIID(ops, 3),
},
}
if len(fr.nibbles) == 0 {
fr.nibbles = []byte{0}
}
ipam.randSrc = fr
net6 := mustNet(t, "2001:db8::/120")
net4 := mustNet(t, "10.0.0.0/28")
var live []AllocResult
seen := map[string]struct{}{}
for idx, op := range ops {
req := AllocRequest{ContainerID: idStr(idx)}
switch op & 0x03 {
case 0:
req.WantV6 = true
case 1:
req.WantV4 = true
case 2:
req.WantV6, req.WantV4 = true, true
case 3:
if len(live) == 0 {
continue
}
rel := live[len(live)-1]
live = live[:len(live)-1]
ipam.Release(rel.IP6, rel.IP4)
delete(seen, canonical(rel.IP6))
delete(seen, canonical(rel.IP4))
continue
}
res, err := ipam.Allocate(req)
if err != nil {
continue // exhaustion is acceptable
}
if req.WantV6 {
if res.IP6 == nil {
t.Fatalf("requested v6 but got nil")
}
if !net6.Contains(res.IP6) {
t.Fatalf("v6 %s outside /120", res.IP6)
}
if _, dup := seen[canonical(res.IP6)]; dup {
t.Fatalf("v6 %s duplicated", res.IP6)
}
seen[canonical(res.IP6)] = struct{}{}
}
if req.WantV4 {
if res.IP4 == nil {
t.Fatalf("requested v4 but got nil")
}
if !net4.Contains(res.IP4) {
t.Fatalf("v4 %s outside /28", res.IP4)
}
v4 := res.IP4.To4()
if v4 == nil {
t.Fatalf("v4 result not 4-byte: %s", res.IP4)
}
// Skip .0 (network) and .15 (broadcast). The allocator
// should also skip .1 (gateway) by convention.
last := v4[3]
if last == 0 || last == 1 || last == 15 {
t.Fatalf("v4 %s in reserved range", res.IP4)
}
if _, dup := seen[canonical(res.IP4)]; dup {
t.Fatalf("v4 %s duplicated", res.IP4)
}
seen[canonical(res.IP4)] = struct{}{}
}
live = append(live, res)
}
})
}
// FuzzCanonical asserts that canonical never panics and is idempotent.
func FuzzCanonical(f *testing.F) {
f.Add([]byte{})
f.Add([]byte{1, 2, 3, 4})
f.Add([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0})
f.Add([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 10, 0, 0, 1}) // v4-mapped v6
f.Add([]byte{0xff})
f.Fuzz(func(t *testing.T, b []byte) {
ip := net.IP(b)
s1 := canonical(ip)
// Idempotent: re-canonicalising the parsed form yields the same
// string for any non-empty result.
if s1 != "" {
parsed := net.ParseIP(s1)
if parsed == nil {
t.Fatalf("canonical(%v)=%q is not parseable as IP", b, s1)
}
if got := canonical(parsed); got != s1 {
t.Fatalf("not idempotent: %q -> %q", s1, got)
}
}
})
}
func makeIID(seed []byte, salt byte) []byte {
out := make([]byte, net.IPv6len)
for i := range out {
if i < len(seed) {
out[i] = seed[i] ^ salt
} else {
out[i] = salt
}
}
return out
}
func idStr(i int) string {
const hex = "0123456789abcdef"
return string([]byte{'c', '-', hex[(i>>4)&0xF], hex[i&0xF]})
}