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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-14 12:14:07 +01:00
commit 3c0808591f
111 files changed
+17851

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
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)
}
}
}