From e0ef681085e75b08cc43f586ea3e60beeb109ce8 Mon Sep 17 00:00:00 2001 From: Marvin Drees Date: Thu, 18 Jun 2026 15:21:25 +0200 Subject: [PATCH] feat: add RFC 3927 support (#114) Signed-off-by: Marvin Drees --- README.md | 2 + ipv4/definitions.go | 8 + ipv4/definitions_test.go | 21 ++ ipv4/linklocal4/definitions.go | 86 ++++++++ ipv4/linklocal4/handler.go | 331 ++++++++++++++++++++++++++++ ipv4/linklocal4/linklocal4_test.go | 332 +++++++++++++++++++++++++++++ 6 files changed, 780 insertions(+) create mode 100644 ipv4/linklocal4/definitions.go create mode 100644 ipv4/linklocal4/handler.go create mode 100644 ipv4/linklocal4/linklocal4_test.go diff --git a/README.md b/README.md index eaa14a3..42d9e75 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ ok github.com/soypat/lneto/x/xnet 2.926s | TCP | RFC 9293 | ✅ | `tcp` | 0 ²³ | Full state machine, SYN cookies, retransmit queue, `Conn`/`Listener` | | DNS | RFC 1035 | ✅ | `dns` | — | Client (A/AAAA query) | | DHCPv4 | RFC 2131 | ✅ | `dhcp/dhcpv4` | — | Client + Server | +| IPv4 Link-Local (APIPA) | RFC 3927 | ✅ | `ipv4/linklocal4` | 0 | Heapless claim-and-defend state machine for 169.254.x.x | | NTP | RFC 5905 | ✅ | `ntp` | — | Client | | NTS | RFC 8915 | ✅ | `x/nts` | — | Client + Server; key exchange over TLS 1.3, authenticated NTP. Requires caller-supplied AEAD_AES_SIV_CMAC_256 | | mDNS | RFC 6762 | ✅ | `dns/mdns` | — | Client (service announcement + query) | @@ -122,6 +123,7 @@ ok github.com/soypat/lneto/x/xnet 2.926s - [`lneto/internet/pcap`](./internal/pcap): Packet capture and field breakdown utilities. Wireshark in the making. - [`lneto/http/httpraw`](./http/httpraw/): Heapless HTTP header processing and validation. Does no implement header normalization. - [`lneto/tcp`](./tcp): TCP implementation and low level logic. +- [`lneto/ipv4/linklocal4`](./ipv4/linklocal4): RFC 3927 IPv4 link-local (APIPA) address autoconfiguration. Heapless claim-and-defend state machine for self-assigned 169.254.x.x addresses when no DHCP server is available. - [`lneto/dhcpv4`](./dhcpv4): DHCP version 4 protocol implementation and low level logic. - [`lneto/dns`](./dns): DNS protocol implementation and low level logic. - [`lneto/ntp`](./ntp): NTP implementation and low level logic. Includes NTP time primitives manipulation and conversion to Go native types. diff --git a/ipv4/definitions.go b/ipv4/definitions.go index 44b5fce..c0ee648 100644 --- a/ipv4/definitions.go +++ b/ipv4/definitions.go @@ -18,6 +18,14 @@ func IsMulticast(addr [4]byte) bool { return addr[0]&0xf0 == 0xe0 } +// IsLinkLocal reports whether addr is within the IPv4 link-local prefix +// 169.254.0.0/16 reserved for dynamic link-local configuration as defined in [RFC3927]. +// +// [RFC3927]: https://datatracker.ietf.org/doc/html/rfc3927 +func IsLinkLocal(addr [4]byte) bool { + return addr[0] == 169 && addr[1] == 254 +} + // ToS represents the Traffic Class (a.k.a Type of Service). It is 8 bits long. 6 MSB are Differentiated Services; 2 LSB are Explicit Congenstion Notification. type ToS uint8 diff --git a/ipv4/definitions_test.go b/ipv4/definitions_test.go index 96b402b..dc6f5af 100644 --- a/ipv4/definitions_test.go +++ b/ipv4/definitions_test.go @@ -31,6 +31,27 @@ func TestAppendFormatAddr(t *testing.T) { } } +func TestIsLinkLocal(t *testing.T) { + tests := []struct { + addr [4]byte + want bool + }{ + {addr: [4]byte{169, 254, 0, 0}, want: true}, + {addr: [4]byte{169, 254, 1, 1}, want: true}, + {addr: [4]byte{169, 254, 254, 255}, want: true}, + {addr: [4]byte{169, 254, 255, 255}, want: true}, + {addr: [4]byte{169, 253, 1, 1}, want: false}, + {addr: [4]byte{169, 255, 1, 1}, want: false}, + {addr: [4]byte{192, 168, 1, 1}, want: false}, + {addr: [4]byte{0, 0, 0, 0}, want: false}, + } + for _, tc := range tests { + if got := IsLinkLocal(tc.addr); got != tc.want { + t.Errorf("IsLinkLocal(%v): got %v, want %v", tc.addr, got, tc.want) + } + } +} + func TestAppendFormatAddr_noAllocs(t *testing.T) { var buf [24]byte addr := [4]byte{192, 168, 1, 1} diff --git a/ipv4/linklocal4/definitions.go b/ipv4/linklocal4/definitions.go new file mode 100644 index 0000000..0984608 --- /dev/null +++ b/ipv4/linklocal4/definitions.go @@ -0,0 +1,86 @@ +// Package linklocal4 implements dynamic configuration of IPv4 link-local addresses +// (a.k.a. APIPA, "self-assigned" 169.254.x.x addresses) as specified in [RFC3927]. +// +// The [Handler] is a heapless state machine that claims and defends a link-local +// address using ARP probes and announcements. It performs no allocations during +// steady-state operation and holds no buffers of its own; the caller provides the +// scratch buffer on each call. This makes it suitable for memory constrained and +// bare-metal targets. +// +// [RFC3927]: https://datatracker.ietf.org/doc/html/rfc3927 +package linklocal4 + +import "time" + +// Protocol constants as defined in [RFC3927] section 9. +// +// [RFC3927]: https://datatracker.ietf.org/doc/html/rfc3927#section-9 +const ( + // probeWait is the initial random delay before sending the first probe. + probeWait = 1 * time.Second + // probeNum is the number of ARP probes to send. + probeNum = 3 + // probeMin and probeMax bound the random spacing between probes. + probeMin = 1 * time.Second + probeMax = 2 * time.Second + // announceWait is the delay after the last probe before announcing. + announceWait = 2 * time.Second + // announceNum is the number of ARP announcements to send. + announceNum = 2 + // announceInterval is the spacing between announcements. + announceInterval = 2 * time.Second + // maxConflicts is the number of conflicts after which probing is rate limited. + maxConflicts = 10 + // rateLimitInterval bounds the rate of address claiming once maxConflicts is exceeded. + rateLimitInterval = 60 * time.Second + // defendInterval is the minimum spacing between defensive announcements before + // the address is abandoned to break an endless defense loop. + defendInterval = 10 * time.Second +) + +// arpIPv4Size is the size of an ARP-over-Ethernet IPv4 packet (RFC826): +// 8 byte fixed header + 2*(6 byte hardware addr + 4 byte protocol addr). +const arpIPv4Size = 28 + +// State represents the stage of the link-local address autoconfiguration +// state machine. The transition order during a successful claim is: +// +// StateWaiting -> StateProbing -> StateAnnouncing -> StateBound +type State uint8 + +const ( + // StateInvalid is the zero value; the handler has not been configured. + StateInvalid State = iota + // StateWaiting is the initial random delay before the first probe is sent. + StateWaiting + // StateProbing sends ARP probes to detect whether the candidate is in use. + StateProbing + // StateAnnouncing has claimed the candidate and is broadcasting announcements. + StateAnnouncing + // StateBound owns the address and defends it against conflicts. + StateBound + // StateRateLimited is waiting out rateLimitInterval after too many conflicts. + StateRateLimited +) + +// IsBound reports whether the state machine has successfully claimed an address. +func (s State) IsBound() bool { return s == StateBound } + +func (s State) String() string { + switch s { + case StateInvalid: + return "invalid" + case StateWaiting: + return "waiting" + case StateProbing: + return "probing" + case StateAnnouncing: + return "announcing" + case StateBound: + return "bound" + case StateRateLimited: + return "rate-limited" + default: + return "linklocal4.State(?)" + } +} diff --git a/ipv4/linklocal4/handler.go b/ipv4/linklocal4/handler.go new file mode 100644 index 0000000..206fa8c --- /dev/null +++ b/ipv4/linklocal4/handler.go @@ -0,0 +1,331 @@ +package linklocal4 + +import ( + "time" + + "github.com/soypat/lneto" + "github.com/soypat/lneto/arp" + "github.com/soypat/lneto/ethernet" + "github.com/soypat/lneto/internal" + "github.com/soypat/lneto/ipv4" +) + +// Handler implements the [RFC3927] IPv4 link-local address autoconfiguration +// state machine. It is a [lneto.StackNode] over the ARP EtherType: it produces +// ARP probes and announcements on [Handler.Encapsulate] and inspects incoming +// ARP traffic for conflicts on [Handler.Demux]. +// +// Handler is heapless and performs zero allocations after [Handler.Reset]; +// the only allocation is the one-time capture of the clock function. It holds +// no internal buffers and operates entirely on the caller-supplied scratch +// buffer, making it suitable for memory constrained targets. +// +// A Handler claims and defends a single address. Normal "who-has" ARP +// resolution of the claimed address is the responsibility of the ARP layer +// (see [arp.Handler]); this Handler only manages the claim-and-defend protocol. +// +// [RFC3927]: https://datatracker.ietf.org/doc/html/rfc3927 +type Handler struct { + connID uint64 + now func() time.Time + + // nextActionAt is the time at which the next probe/announcement is due. + nextActionAt time.Time + // lastDefend is the time the most recent defensive announcement was sent. + lastDefend time.Time + + prng uint32 + candidate [4]byte + firstCandidate [4]byte + hw [6]byte + + state State + probesSent uint8 + announceSent uint8 + conflicts uint8 + + haveFirst bool + defendDue bool + defendValid bool + vld lneto.Validator +} + +var _ lneto.StackNode = (*Handler)(nil) + +// linkLocalNet is the RFC3927 IPv4 link-local prefix (169.254.0.0/16). The first +// and last /24 within it (169.254.0.x and 169.254.255.x) are reserved per +// section 2.1, so the usable host range is 169.254.1.0-169.254.254.255. +var linkLocalNet = ipv4.PrefixFrom([4]byte{169, 254, 0, 0}, 16) + +// Config configures a [Handler] for link-local address acquisition. +type Config struct { + // HardwareAddr is the interface MAC address used as the ARP sender hardware address. + HardwareAddr [6]byte + // Now is the monotonic clock source used to schedule probes and announcements. + // It is required. + Now func() time.Time + // Seed seeds the pseudo-random address generator. It is required and must be + // non-zero. Per RFC3927 section 2.1 it SHOULD be derived from a persistent + // per-host value such as the MAC address so that different hosts pick different + // sequences and a host tends to reuse the same address across reboots. + Seed uint64 + // FirstCandidate, if within 169.254.1.0-169.254.254.255, is tried before any + // random address. Use it to retry a previously recorded address. + FirstCandidate [4]byte +} + +// Reset configures the handler and begins link-local address acquisition, +// transitioning to [StateWaiting]. It increments the connection ID, invalidating +// any prior registration. +func (h *Handler) Reset(cfg Config) error { + if cfg.Now == nil || internal.IsZeroed(cfg.HardwareAddr[:]...) || cfg.Seed == 0 { + return lneto.ErrInvalidConfig + } + first := cfg.FirstCandidate + haveFirst := linkLocalNet.Contains(first) && first[2] >= 1 && first[2] <= 254 + *h = Handler{ + connID: h.connID + 1, + now: cfg.Now, + prng: uint32(cfg.Seed) ^ uint32(cfg.Seed>>32), + hw: cfg.HardwareAddr, + firstCandidate: first, + haveFirst: haveFirst, + } + if h.prng == 0 { + h.prng = 1 // Fold of a non-zero seed can still be zero; xorshift cannot escape the zero state. + } + h.beginProbing(cfg.Now(), randDelay(h.prand(), probeWait)) + return nil +} + +// LocalPort implements [lneto.StackNode]. It always returns 0. +func (h *Handler) LocalPort() uint16 { return 0 } + +// Protocol implements [lneto.StackNode], returning the ARP EtherType. +func (h *Handler) Protocol() uint64 { return uint64(ethernet.TypeARP) } + +// ConnectionID implements [lneto.StackNode]. +func (h *Handler) ConnectionID() *uint64 { return &h.connID } + +// State returns the current autoconfiguration state. +func (h *Handler) State() State { return h.state } + +// Addr returns the claimed link-local address. ok is true only once the address +// has been successfully claimed (state [StateBound]). +func (h *Handler) Addr() (addr [4]byte, ok bool) { + return h.candidate, h.state == StateBound +} + +// Candidate returns the address currently being probed, announced or defended. +func (h *Handler) Candidate() [4]byte { return h.candidate } + +// Conflicts returns the number of address conflicts encountered so far. +func (h *Handler) Conflicts() int { return int(h.conflicts) } + +// Encapsulate implements [lneto.StackNode]. It writes the next ARP probe or +// announcement into carrierData at offsetToFrame when one is due, returning the +// number of bytes written, or 0 when no action is pending. The Ethernet +// destination, if present before offsetToFrame, is set to broadcast. +func (h *Handler) Encapsulate(carrierData []byte, _, offsetToFrame int) (int, error) { + if offsetToFrame < 0 || len(carrierData)-offsetToFrame < arpIPv4Size { + return 0, lneto.ErrShortBuffer + } + now := h.now() + b := carrierData[offsetToFrame:] + var senderProto [4]byte // zero = ARP probe; candidate = ARP announcement. + switch h.state { + case StateWaiting, StateProbing: + if now.Before(h.nextActionAt) { + return 0, nil + } + if h.probesSent < probeNum { + h.state = StateProbing + h.probesSent++ + if h.probesSent < probeNum { + h.nextActionAt = now.Add(randInterval(h.prand(), probeMin, probeMax)) + } else { + h.nextActionAt = now.Add(announceWait) + } + // senderProto stays zero: this is a probe. + } else { + // announceWait elapsed with no conflict: claim the address. + h.state = StateAnnouncing + h.announceSent = 1 + h.nextActionAt = now.Add(announceInterval) + senderProto = h.candidate + } + + case StateAnnouncing: + if now.Before(h.nextActionAt) { + return 0, nil + } + h.announceSent++ + senderProto = h.candidate + if h.announceSent >= announceNum { + h.state = StateBound + } else { + h.nextActionAt = now.Add(announceInterval) + } + + case StateBound: + if !h.defendDue { + return 0, nil + } + h.defendDue = false + senderProto = h.candidate + + case StateRateLimited: + if now.Before(h.nextActionAt) { + return 0, nil + } + // onConflict already selected a fresh candidate; resume probing it. + h.beginProbing(now, randDelay(h.prand(), probeWait)) + return 0, nil + + default: + return 0, nil + } + + h.putARP(b, senderProto) + if offsetToFrame >= 14 { + // TODO: Support VLAN-tagged Ethernet headers when setting the broadcast destination. + broadcast := ethernet.BroadcastAddr() + copy(carrierData[offsetToFrame-14:offsetToFrame-8], broadcast[:]) + } + return arpIPv4Size, nil +} + +// Demux implements [lneto.StackNode]. It inspects an incoming ARP frame for +// address conflicts per RFC3927 sections 2.2.1 and 2.5, updating the state +// machine to reconfigure or defend as required. +func (h *Handler) Demux(carrierData []byte, frameOffset int) error { + if h.state == StateInvalid { + return nil + } + afrm, err := arp.NewFrame(carrierData[frameOffset:]) + if err != nil { + return err + } + h.vld.ResetErr() + afrm.ValidateSize(&h.vld) + if h.vld.HasError() { + return h.vld.ErrPop() + } + ptype, plen := afrm.Protocol() + if ptype != ethernet.TypeIPv4 || plen != 4 { + return nil // Not IPv4 ARP; irrelevant to link-local conflict detection. + } + senderHW, senderProto := afrm.Sender4() + _, targetProto := afrm.Target4() + now := h.now() + + switch h.state { + case StateWaiting, StateProbing: + // Conflict if anyone else uses the candidate as a sender address, or + // is probing for the same candidate from a different hardware address. + conflict := *senderProto == h.candidate || + (afrm.Operation() == arp.OpRequest && internal.IsZeroed(senderProto[:]...) && + *targetProto == h.candidate && *senderHW != h.hw) + if conflict { + h.onConflict(now) + } + + case StateAnnouncing, StateBound: + // We own the address; a conflicting sender hardware address means another + // host claims it too. + if *senderProto == h.candidate && *senderHW != h.hw { + h.onDefend(now) + } + } + return nil +} + +// onConflict handles a conflict detected while probing: pick a new candidate and +// restart, rate limiting once maxConflicts is exceeded. +func (h *Handler) onConflict(now time.Time) { + if h.conflicts < 255 { + h.conflicts++ + } + h.selectCandidate() + if h.conflicts > maxConflicts { + h.state = StateRateLimited + h.nextActionAt = now.Add(rateLimitInterval) + return + } + h.beginProbing(now, randDelay(h.prand(), probeWait)) +} + +// onDefend handles a conflict on an address we own per RFC3927 section 2.5(b): +// defend once with a single announcement, but abandon the address if conflicts +// recur within defendInterval to avoid an endless defense loop. +func (h *Handler) onDefend(now time.Time) { + if !h.defendValid || now.Sub(h.lastDefend) >= defendInterval { + h.lastDefend = now + h.defendValid = true + h.defendDue = true + return + } + // Second conflict within defendInterval: give up and reconfigure. + h.onConflict(now) +} + +// beginProbing resets probe/announce counters and schedules the first probe after delay. +func (h *Handler) beginProbing(now time.Time, delay time.Duration) { + if internal.IsZeroed(h.candidate[:]...) { + h.selectCandidate() + } + h.state = StateWaiting + h.probesSent = 0 + h.announceSent = 0 + h.defendDue = false + h.defendValid = false + h.nextActionAt = now.Add(delay) +} + +// selectCandidate picks the next address to try. It uses FirstCandidate once if +// provided, otherwise a uniform pseudo-random address in 169.254.1.0-169.254.254.255 +// per RFC3927 section 2.1 (the first and last /24 are reserved). +func (h *Handler) selectCandidate() { + if h.haveFirst { + h.haveFirst = false + h.candidate = h.firstCandidate + return + } + // 254*256 = 65024 usable addresses; offset by one /24 to skip 169.254.0.x. + low := 256 + h.prand()%65024 + h.candidate = [4]byte{169, 254, byte(low >> 8), byte(low)} +} + +// putARP marshals an ARP request (probe or announcement) into dst. A probe has +// an all-zero sender protocol address; an announcement repeats the candidate. +func (h *Handler) putARP(dst []byte, senderProto [4]byte) { + f, _ := arp.NewFrame(dst) + f.SetHardware(1, 6) + f.SetProtocol(ethernet.TypeIPv4, 4) + f.SetOperation(arp.OpRequest) + shw, sproto := f.Sender4() + *shw = h.hw + *sproto = senderProto + thw, tproto := f.Target4() + *thw = [6]byte{} // Target hardware address ignored; set to zero per RFC3927 section 2.2.1. + *tproto = h.candidate +} + +func (h *Handler) prand() uint32 { + h.prng = internal.Prand32(h.prng) + return h.prng +} + +// randDelay returns a duration uniformly in [0, max] derived from r. +func randDelay(r uint32, max time.Duration) time.Duration { + return time.Duration(uint64(r) % uint64(max+1)) +} + +// randInterval returns a duration uniformly in [min, max] derived from r. +func randInterval(r uint32, min, max time.Duration) time.Duration { + span := max - min + if span <= 0 { + return min + } + return min + time.Duration(uint64(r)%uint64(span+1)) +} diff --git a/ipv4/linklocal4/linklocal4_test.go b/ipv4/linklocal4/linklocal4_test.go new file mode 100644 index 0000000..b36cb8e --- /dev/null +++ b/ipv4/linklocal4/linklocal4_test.go @@ -0,0 +1,332 @@ +package linklocal4 + +import ( + "testing" + "time" + + "github.com/soypat/lneto" + "github.com/soypat/lneto/arp" + "github.com/soypat/lneto/ethernet" + "github.com/soypat/lneto/ipv4" +) + +type fakeClock struct{ t time.Time } + +func (c *fakeClock) now() time.Time { return c.t } +func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) } + +var ( + ourHW = [6]byte{0x02, 0x00, 0x00, 0x00, 0x00, 0x01} + otherHW = [6]byte{0x02, 0x00, 0x00, 0x00, 0x00, 0x02} +) + +const frameOff = 14 // pretend there is an ethernet header before the ARP frame. + +func newHandler(t *testing.T, clk *fakeClock) *Handler { + t.Helper() + var h Handler + err := h.Reset(Config{ + HardwareAddr: ourHW, + Now: clk.now, + Seed: 0xC0FFEE, + }) + if err != nil { + t.Fatal(err) + } + if h.State() != StateWaiting { + t.Fatalf("expected StateWaiting after Reset, got %s", h.State()) + } + return &h +} + +// step advances the clock past any pending interval and runs one Encapsulate. +func step(t *testing.T, h *Handler, clk *fakeClock, buf []byte) (arp.Frame, int) { + t.Helper() + clk.advance(3 * time.Second) // larger than any RFC3927 probe/announce interval. + n, err := h.Encapsulate(buf, -1, frameOff) + if err != nil { + t.Fatal(err) + } + if n == 0 { + return arp.Frame{}, 0 + } + f, err := arp.NewFrame(buf[frameOff : frameOff+n]) + if err != nil { + t.Fatalf("invalid arp produced: %v", err) + } + var vld lneto.Validator + f.ValidateSize(&vld) + if vld.HasError() { + t.Fatalf("invalid arp size: %v", vld.ErrPop()) + } + if f.Operation() != arp.OpRequest { + t.Fatalf("link-local ARP must be a request, got %s", f.Operation()) + } + // Ethernet destination must be broadcast. + bc := ethernet.BroadcastAddr() + for i := range 6 { + if buf[i] != bc[i] { + t.Fatalf("ethernet destination not broadcast: %x", buf[:6]) + } + } + return f, n +} + +func TestClaim(t *testing.T) { + clk := &fakeClock{t: time.Unix(1000, 0)} + h := newHandler(t, clk) + buf := make([]byte, 64) + + cand := h.Candidate() + if !ipv4.IsLinkLocal(cand) || cand[2] < 1 || cand[2] > 254 { + t.Fatalf("candidate %v not a valid link-local address", cand) + } + + var probes, announces int + for i := 0; i < 10 && h.State() != StateBound; i++ { + f, n := step(t, h, clk, buf) + if n == 0 { + continue + } + _, sproto := f.Sender4() + _, tproto := f.Target4() + shw, _ := f.Sender4() + if *shw != ourHW { + t.Fatalf("sender hardware address mismatch: %x", *shw) + } + if *tproto != cand { + t.Fatalf("target proto must be candidate %v, got %v", cand, *tproto) + } + if *sproto == ([4]byte{}) { + probes++ + } else if *sproto == cand { + announces++ + } else { + t.Fatalf("unexpected sender proto %v", *sproto) + } + } + if h.State() != StateBound { + t.Fatalf("expected StateBound, got %s", h.State()) + } + if probes != probeNum { + t.Errorf("expected %d probes, got %d", probeNum, probes) + } + if announces != announceNum { + t.Errorf("expected %d announcements, got %d", announceNum, announces) + } + addr, ok := h.Addr() + if !ok || addr != cand { + t.Fatalf("Addr()=%v,%v want %v,true", addr, ok, cand) + } +} + +// makeARP builds an ARP IPv4 frame in buf for conflict-detection tests. +func makeARP(t *testing.T, buf []byte, op arp.Operation, senderHW [6]byte, senderProto, targetProto [4]byte) []byte { + t.Helper() + f, err := arp.NewFrame(buf) + if err != nil { + t.Fatal(err) + } + f.SetHardware(1, 6) + f.SetProtocol(ethernet.TypeIPv4, 4) + f.SetOperation(op) + shw, sp := f.Sender4() + *shw = senderHW + *sp = senderProto + thw, tp := f.Target4() + *thw = [6]byte{} + *tp = targetProto + return buf[:arpIPv4Size] +} + +func TestConflictDuringProbe(t *testing.T) { + clk := &fakeClock{t: time.Unix(1000, 0)} + h := newHandler(t, clk) + buf := make([]byte, 64) + + // Send the first probe. + _, n := step(t, h, clk, buf) + if n == 0 { + t.Fatal("expected first probe") + } + cand := h.Candidate() + + // Another host replies/uses the candidate as its sender address: conflict. + var arpbuf [64]byte + frame := makeARP(t, arpbuf[:], arp.OpReply, otherHW, cand, [4]byte{169, 254, 1, 1}) + if err := h.Demux(frame, 0); err != nil { + t.Fatal(err) + } + if h.Conflicts() != 1 { + t.Fatalf("expected 1 conflict, got %d", h.Conflicts()) + } + if h.Candidate() == cand { + t.Fatal("expected a new candidate after conflict") + } + if h.State() != StateWaiting { + t.Fatalf("expected restart in StateWaiting, got %s", h.State()) + } +} + +func TestProbeConflictFromSimultaneousProbe(t *testing.T) { + clk := &fakeClock{t: time.Unix(1000, 0)} + h := newHandler(t, clk) + buf := make([]byte, 64) + step(t, h, clk, buf) // first probe + cand := h.Candidate() + + // Another host probes for the same candidate (zero sender proto, different HW). + var arpbuf [64]byte + frame := makeARP(t, arpbuf[:], arp.OpRequest, otherHW, [4]byte{}, cand) + if err := h.Demux(frame, 0); err != nil { + t.Fatal(err) + } + if h.Conflicts() != 1 || h.Candidate() == cand { + t.Fatalf("simultaneous probe should cause conflict: conflicts=%d cand=%v", h.Conflicts(), h.Candidate()) + } +} + +func driveToBound(t *testing.T, h *Handler, clk *fakeClock, buf []byte) { + t.Helper() + for i := 0; i < 10 && h.State() != StateBound; i++ { + step(t, h, clk, buf) + } + if h.State() != StateBound { + t.Fatalf("failed to reach StateBound, stuck at %s", h.State()) + } +} + +func TestDefense(t *testing.T) { + clk := &fakeClock{t: time.Unix(1000, 0)} + h := newHandler(t, clk) + buf := make([]byte, 64) + driveToBound(t, h, clk, buf) + cand := h.Candidate() + + // A conflicting ARP from another host: handler should defend with one announcement. + var arpbuf [64]byte + frame := makeARP(t, arpbuf[:], arp.OpRequest, otherHW, cand, [4]byte{169, 254, 1, 1}) + if err := h.Demux(frame, 0); err != nil { + t.Fatal(err) + } + n, err := h.Encapsulate(buf, -1, frameOff) + if err != nil || n == 0 { + t.Fatalf("expected defensive announcement, n=%d err=%v", n, err) + } + f, _ := arp.NewFrame(buf[frameOff : frameOff+n]) + _, sproto := f.Sender4() + if *sproto != cand { + t.Fatalf("defensive announcement must use candidate as sender, got %v", *sproto) + } + if h.State() != StateBound { + t.Fatalf("should remain bound after a single defense, got %s", h.State()) + } + // Subsequent Encapsulate yields nothing more. + if n, _ := h.Encapsulate(buf, -1, frameOff); n != 0 { + t.Fatal("expected only a single defensive announcement") + } + + // A second conflict within defendInterval forces reconfiguration. + clk.advance(defendInterval / 2) + frame = makeARP(t, arpbuf[:], arp.OpReply, otherHW, cand, [4]byte{169, 254, 1, 1}) + if err := h.Demux(frame, 0); err != nil { + t.Fatal(err) + } + if h.State() == StateBound { + t.Fatal("expected reconfiguration after repeated conflict within defendInterval") + } + if h.Candidate() == cand { + t.Fatal("expected new candidate after giving up address") + } +} + +func TestNoSelfConflict(t *testing.T) { + clk := &fakeClock{t: time.Unix(1000, 0)} + h := newHandler(t, clk) + buf := make([]byte, 64) + driveToBound(t, h, clk, buf) + cand := h.Candidate() + + // Our own announcement (same hardware address) must not be treated as a conflict. + var arpbuf [64]byte + frame := makeARP(t, arpbuf[:], arp.OpRequest, ourHW, cand, cand) + if err := h.Demux(frame, 0); err != nil { + t.Fatal(err) + } + if n, _ := h.Encapsulate(buf, -1, frameOff); n != 0 { + t.Fatal("self-sent ARP must not trigger a defense") + } + if h.State() != StateBound { + t.Fatalf("state changed on self ARP: %s", h.State()) + } +} + +func TestFirstCandidate(t *testing.T) { + clk := &fakeClock{t: time.Unix(1000, 0)} + want := [4]byte{169, 254, 42, 7} + var h Handler + err := h.Reset(Config{ + HardwareAddr: ourHW, + Now: clk.now, + Seed: 0xC0FFEE, + FirstCandidate: want, + }) + if err != nil { + t.Fatal(err) + } + if h.Candidate() != want { + t.Fatalf("expected FirstCandidate %v, got %v", want, h.Candidate()) + } +} + +func TestRateLimit(t *testing.T) { + clk := &fakeClock{t: time.Unix(1000, 0)} + h := newHandler(t, clk) + buf := make([]byte, 64) + var arpbuf [64]byte + // Force more than maxConflicts conflicts during probing. + for i := 0; i <= maxConflicts; i++ { + step(t, h, clk, buf) // emit a probe for the current candidate. + cand := h.Candidate() + frame := makeARP(t, arpbuf[:], arp.OpReply, otherHW, cand, [4]byte{169, 254, 1, 1}) + if err := h.Demux(frame, 0); err != nil { + t.Fatal(err) + } + } + if h.State() != StateRateLimited { + t.Fatalf("expected StateRateLimited after %d conflicts, got %s", h.Conflicts(), h.State()) + } + // Before rateLimitInterval elapses no probe is emitted. + if n, _ := h.Encapsulate(buf, -1, frameOff); n != 0 { + t.Fatal("must not probe while rate limited") + } + // After the interval the machine resumes probing. + clk.advance(rateLimitInterval + time.Second) + if n, _ := h.Encapsulate(buf, -1, frameOff); n != 0 { + t.Fatal("rate-limit recovery should reschedule, not emit immediately") + } + if h.State() != StateWaiting { + t.Fatalf("expected StateWaiting after rate-limit recovery, got %s", h.State()) + } +} + +func TestZeroAlloc(t *testing.T) { + clk := &fakeClock{t: time.Unix(1000, 0)} + h := newHandler(t, clk) + buf := make([]byte, 64) + driveToBound(t, h, clk, buf) + cand := h.Candidate() + var arpbuf [64]byte + frame := makeARP(t, arpbuf[:], arp.OpRequest, otherHW, cand, [4]byte{1, 2, 3, 4}) + + if n := testing.AllocsPerRun(100, func() { + _, _ = h.Encapsulate(buf, -1, frameOff) + }); n != 0 { + t.Errorf("Encapsulate allocated %g times, want 0", n) + } + if n := testing.AllocsPerRun(100, func() { + _ = h.Demux(frame, 0) + }); n != 0 { + t.Errorf("Demux allocated %g times, want 0", n) + } +}