Files
abrown38andClaude Opus 5.5 3c0808591f Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three
levels, leases in SQLite saved before the ACK, ping and decline conflict
checks, validated changes with history and undo, users and tokens, an
audit log, a Kea importer, a sandboxed systemd unit and Debian packages.
Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:07 +01:00

179 lines
5.5 KiB
Go

package dhcp4
import (
"bytes"
"net"
"net/netip"
"strings"
"testing"
)
func TestRoundTrip(t *testing.T) {
mac, _ := net.ParseMAC("aa:bb:cc:00:11:22")
p := NewRequest(Discover, mac, 0x12345678)
p.Flags = FlagBroadcast
p.Set(OptHostName, []byte("laptop"))
p.Set(OptParamRequest, []byte{1, 3, 6, 15})
p.Set(OptClientID, append([]byte{1}, mac...))
b := p.Marshal()
if len(b) < MinLen {
t.Fatalf("message is %d bytes, under %d", len(b), MinLen)
}
if p.Len() != len(b) {
t.Errorf("Len() = %d, Marshal gave %d", p.Len(), len(b))
}
q, err := Parse(b)
if err != nil {
t.Fatal(err)
}
if q.Type() != Discover || q.XID != 0x12345678 || !q.Broadcast() || q.MAC().String() != mac.String() {
t.Errorf("parsed %+v", q)
}
if q.HostName() != "laptop" || q.ClientID() != "01aabbcc001122" {
t.Errorf("host %q client id %q", q.HostName(), q.ClientID())
}
if !bytes.Equal(q.ParamRequest(), []byte{1, 3, 6, 15}) {
t.Errorf("PRL %v", q.ParamRequest())
}
r := q.Reply(Offer)
r.YIAddr = netip.MustParseAddr("10.0.10.57")
if r.Op != BootReply || r.XID != q.XID || r.Type() != Offer || r.CHAddr != q.CHAddr {
t.Errorf("reply %+v", r)
}
r2, err := Parse(r.Marshal())
if err != nil || r2.YIAddr != r.YIAddr {
t.Fatalf("reply round trip: %v %v", r2, err)
}
}
func TestLongOptionsAreSplitAndJoined(t *testing.T) {
p := NewRequest(Inform, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1)
long := bytes.Repeat([]byte{7}, 600)
p.Set(43, long)
b := p.Marshal()
q, err := Parse(b)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(q.Get(43), long) {
t.Errorf("got %d bytes back, want 600", len(q.Get(43)))
}
if p.Len() != len(b) {
t.Errorf("Len() = %d, Marshal gave %d", p.Len(), len(b))
}
}
func TestOverload(t *testing.T) {
p := NewRequest(Request, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1)
p.Set(OptOverload, []byte{1})
b := p.Marshal()
// Put option 12 in the file field (bytes 108..236).
copy(b[108:], []byte{12, 3, 'p', 'c', '1', 255})
q, err := Parse(b)
if err != nil {
t.Fatal(err)
}
if q.HostName() != "pc1" || q.File != "" {
t.Errorf("host %q file %q", q.HostName(), q.File)
}
}
func TestParseErrors(t *testing.T) {
if _, err := Parse(make([]byte, 100)); err == nil {
t.Error("short message accepted")
}
b := NewRequest(Discover, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1).Marshal()
b[236] = 0
if _, err := Parse(b); err == nil || !strings.Contains(err.Error(), "cookie") {
t.Errorf("missing cookie: %v", err)
}
b = NewRequest(Discover, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1).Marshal()
b[240+1] = 200 // message type option claims 200 bytes
if _, err := Parse(b); err == nil {
t.Error("overlong option accepted")
}
}
func TestHostNameFromFQDN(t *testing.T) {
p := NewRequest(Request, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1)
wire, _ := encodeNames([]string{"desk.example.com"})
p.Set(OptClientFQDN, append([]byte{0x04, 0, 0}, wire...))
if got := p.HostName(); got != "desk" {
t.Errorf("wire FQDN: %q", got)
}
p.Set(OptClientFQDN, append([]byte{0, 0, 0}, "tv.lan"...))
if got := p.HostName(); got != "tv" {
t.Errorf("ASCII FQDN: %q", got)
}
}
func TestEncodeDecode(t *testing.T) {
cases := []struct {
typ, in, out string
hex string
}{
{TypeIP, " 10.0.0.1 ", "10.0.0.1", "0a000001"},
{TypeIPs, "10.0.0.1,10.0.0.2", "10.0.0.1, 10.0.0.2", "0a0000010a000002"},
{TypeU8, "8", "8", "08"},
{TypeU16, "1500", "1500", "05dc"},
{TypeU32, "86400", "86400", "00015180"},
{TypeI32, "-3600", "-3600", "fffff1f0"},
{TypeBool, "yes", "true", "01"},
{TypeHex, "01:02:0A", "01:02:0a", "01020a"},
{TypeHex, "0x0102", "01:02", "0102"},
{TypeDomains, "lan, Example.com.", "lan, example.com", "036c616e00076578616d706c6503636f6d00"},
{TypeRoutes, "10.20.0.0/16 via 10.0.10.1, 0.0.0.0/0 - 10.0.10.1", "10.20.0.0/16 via 10.0.10.1, 0.0.0.0/0 via 10.0.10.1", "100a140a000a01000a000a01"},
}
for _, c := range cases {
b, err := Encode(c.typ, c.in)
if err != nil {
t.Errorf("%s %q: %v", c.typ, c.in, err)
continue
}
if got := Decode(c.typ, b); got != c.out {
t.Errorf("%s %q decodes as %q, want %q", c.typ, c.in, got, c.out)
}
if got := Decode(TypeHex, b); strings.ReplaceAll(got, ":", "") != c.hex {
t.Errorf("%s %q encodes as %s, want %s", c.typ, c.in, got, c.hex)
}
}
bad := []struct{ typ, in string }{
{TypeIP, "10.0.0.256"}, {TypeIP, "::1"}, {TypeIPs, ""}, {TypeU8, "256"}, {TypeU16, "-1"},
{TypeBool, "maybe"}, {TypeHex, "zz"}, {TypeDomains, "a..b"}, {TypeDomains, "bad!name"},
{TypeRoutes, "10.20.0.0/16"}, {TypeRoutes, "10.20.0.1/16 via 10.0.0.1"}, {TypeString, ""}, {"nope", "x"},
}
for _, c := range bad {
if _, err := Encode(c.typ, c.in); err == nil {
t.Errorf("%s %q accepted", c.typ, c.in)
}
}
}
func TestDecodeCompressedNames(t *testing.T) {
// "lan" then "example.lan" written with a pointer to the first "lan".
b := []byte{3, 'l', 'a', 'n', 0, 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 0xc0, 0}
if got := Decode(TypeDomains, b); got != "lan, example.lan" {
t.Errorf("got %q", got)
}
}
func TestLookup(t *testing.T) {
if d, ok := Lookup("routers"); !ok || d.Code != 3 {
t.Errorf("routers: %v %v", d, ok)
}
if d, ok := Lookup("42"); !ok || d.Name != "ntp-servers" {
t.Errorf("42: %v %v", d, ok)
}
if d, ok := Lookup("224"); !ok || d.Type != TypeHex || d.Name != "option-224" {
t.Errorf("224: %v %v", d, ok)
}
if _, ok := Lookup("no-such-option"); ok {
t.Error("unknown name found")
}
for _, d := range Catalog {
if _, ok := Reserved[d.Code]; ok {
t.Errorf("%s (%d) is in the catalog and reserved", d.Name, d.Code)
}
}
}