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

+444
View File
@@ -0,0 +1,444 @@
package dhcp4
import (
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"math"
"net/netip"
"sort"
"strconv"
"strings"
)
// Value types of options, as written in Steward's configuration.
const (
TypeIP = "ip" // one IPv4 address
TypeIPs = "ips" // a list of IPv4 addresses: "10.0.0.1, 10.0.0.2"
TypeString = "string" // text
TypeU8 = "uint8" // 0-255
TypeU16 = "uint16" // 0-65535
TypeU32 = "uint32" // 0-4294967295
TypeI32 = "int32" // a signed number
TypeBool = "bool" // true or false
TypeDomains = "domains" // a list of domain names, in DNS wire format (option 119)
TypeRoutes = "routes" // classless static routes (option 121): "10.20.0.0/16 via 10.0.10.1"
TypeHex = "hex" // raw bytes: "01:02:0a" or "01020a"
)
// Types lists the value types, for custom options.
var Types = []string{TypeIP, TypeIPs, TypeString, TypeU8, TypeU16, TypeU32, TypeI32, TypeBool, TypeDomains, TypeRoutes, TypeHex}
// Def describes an option Steward knows.
type Def struct {
Code byte `json:"code"`
Name string `json:"name"` // Kea's name for it, so imports and habits carry over
Type string `json:"type"`
// Help is a one-line description for the web UI and the CLI.
Help string `json:"help"`
// Common options are offered first in the web UI.
Common bool `json:"common,omitempty"`
}
// Catalog holds the options Steward knows by name. Any other option can
// be set by its number, with a type (hex when not given).
var Catalog = []Def{
{Code: 2, Name: "time-offset", Type: TypeI32, Help: "the client's offset from UTC in seconds (obsolete; prefer tcode)"},
{Code: 3, Name: "routers", Type: TypeIPs, Help: "default gateways, in order of preference", Common: true},
{Code: 4, Name: "time-servers", Type: TypeIPs, Help: "RFC 868 time servers (rarely used; see ntp-servers)"},
{Code: 6, Name: "domain-name-servers", Type: TypeIPs, Help: "DNS servers, in order of preference", Common: true},
{Code: 7, Name: "log-servers", Type: TypeIPs, Help: "MIT-LCS UDP log servers"},
{Code: 15, Name: "domain-name", Type: TypeString, Help: "the domain the client's host name belongs to", Common: true},
{Code: 19, Name: "ip-forwarding", Type: TypeBool, Help: "whether the client should forward packets"},
{Code: 23, Name: "default-ip-ttl", Type: TypeU8, Help: "the TTL for the client's outgoing packets"},
{Code: 26, Name: "interface-mtu", Type: TypeU16, Help: "the MTU of the client's interface", Common: true},
{Code: 28, Name: "broadcast-address", Type: TypeIP, Help: "the subnet's broadcast address"},
{Code: 31, Name: "perform-router-discovery", Type: TypeBool, Help: "whether the client should solicit routers (RFC 1256)"},
{Code: 42, Name: "ntp-servers", Type: TypeIPs, Help: "NTP servers, in order of preference", Common: true},
{Code: 43, Name: "vendor-encapsulated-options", Type: TypeHex, Help: "vendor-specific information"},
{Code: 44, Name: "netbios-name-servers", Type: TypeIPs, Help: "NetBIOS name servers (WINS)"},
{Code: 46, Name: "netbios-node-type", Type: TypeU8, Help: "NetBIOS node type: 1 B, 2 P, 4 M, 8 H"},
{Code: 47, Name: "netbios-scope", Type: TypeString, Help: "NetBIOS scope"},
{Code: 60, Name: "vendor-class-identifier", Type: TypeString, Help: "the server's vendor class (PXE: PXEClient)"},
{Code: 66, Name: "tftp-server-name", Type: TypeString, Help: "the TFTP server for network boot"},
{Code: 67, Name: "boot-file-name", Type: TypeString, Help: "the file to boot from the TFTP server"},
{Code: 69, Name: "smtp-server", Type: TypeIPs, Help: "mail servers"},
{Code: 100, Name: "pcode", Type: TypeString, Help: "the time zone as a POSIX TZ string (EST5EDT,M3.2.0,M11.1.0)"},
{Code: 101, Name: "tcode", Type: TypeString, Help: "the time zone by name (Europe/London)"},
{Code: 114, Name: "v4-captive-portal", Type: TypeString, Help: "the captive portal's API URL (RFC 8910)"},
{Code: 119, Name: "domain-search", Type: TypeDomains, Help: "domains to try when looking up short names", Common: true},
{Code: 121, Name: "classless-static-route", Type: TypeRoutes, Help: "extra routes: \"10.20.0.0/16 via 10.0.10.1\" (clients that use it ignore routers, so add 0.0.0.0/0 too)"},
{Code: 150, Name: "tftp-server-address", Type: TypeIPs, Help: "TFTP servers by address (Cisco phones and others)"},
{Code: 252, Name: "wpad-url", Type: TypeString, Help: "web proxy auto-discovery URL"},
}
// Reserved options are set by the server itself and can't be configured.
var Reserved = map[byte]string{
0: "pad",
1: "the subnet mask comes from the subnet's prefix",
12: "set the reservation's host name instead",
50: "only clients send the requested address",
51: "set the lease time instead",
52: "the server handles option overload itself",
53: "the message type is set by the server",
54: "the server identifier is the interface's address",
55: "only clients send the parameter request list",
56: "the server writes its own messages",
57: "only clients send the maximum message size",
58: "the renewal time is half the lease time",
59: "the rebinding time is 7/8 of the lease time",
61: "only clients send a client identifier",
82: "relay agent information is only added by relays",
255: "end",
}
var byCode, byName = func() (map[byte]Def, map[string]Def) {
c, n := map[byte]Def{}, map[string]Def{}
for _, d := range Catalog {
c[d.Code], n[d.Name] = d, d
}
return c, n
}()
// Lookup finds an option by name ("routers") or number ("3").
func Lookup(nameOrCode string) (Def, bool) {
s := strings.ToLower(strings.TrimSpace(nameOrCode))
if d, ok := byName[s]; ok {
return d, true
}
if n, err := strconv.Atoi(strings.TrimPrefix(s, "option-")); err == nil && n > 0 && n < 255 {
if d, ok := byCode[byte(n)]; ok {
return d, true
}
return Def{Code: byte(n), Name: "option-" + strconv.Itoa(n), Type: TypeHex}, true
}
return Def{}, false
}
// ByCode returns the definition of an option code: the catalog's, or a
// hex one for codes it doesn't know.
func ByCode(code byte) Def {
if d, ok := byCode[code]; ok {
return d
}
return Def{Code: code, Name: "option-" + strconv.Itoa(int(code)), Type: TypeHex}
}
// Known reports whether the catalog has the code.
func Known(code byte) bool {
_, ok := byCode[code]
return ok
}
// Names lists the catalog's names, sorted.
func Names() []string {
var out []string
for n := range byName {
out = append(out, n)
}
sort.Strings(out)
return out
}
// Encode turns an option value written as text into its bytes, for the
// given type.
func Encode(typ, value string) ([]byte, error) {
v := strings.TrimSpace(value)
switch typ {
case TypeIP:
a, err := parseIP4(v)
if err != nil {
return nil, err
}
return IP(a), nil
case TypeIPs:
items := splitList(v)
if len(items) == 0 {
return nil, errors.New("give at least one address")
}
var out []byte
for _, s := range items {
a, err := parseIP4(s)
if err != nil {
return nil, err
}
out = append(out, IP(a)...)
}
return out, nil
case TypeString:
if v == "" {
return nil, errors.New("the value is empty")
}
if len(value) > 255 {
return nil, errors.New("the text is longer than 255 bytes")
}
return []byte(value), nil
case TypeU8, TypeU16, TypeU32:
bits := map[string]int{TypeU8: 8, TypeU16: 16, TypeU32: 32}[typ]
n, err := strconv.ParseUint(v, 10, bits)
if err != nil {
return nil, fmt.Errorf("%q is not a whole number from 0 to %d", v, uint64(1)<<bits-1)
}
switch bits {
case 8:
return []byte{byte(n)}, nil
case 16:
return binary.BigEndian.AppendUint16(nil, uint16(n)), nil
}
return U32(uint32(n)), nil
case TypeI32:
n, err := strconv.ParseInt(v, 10, 32)
if err != nil {
return nil, fmt.Errorf("%q is not a whole number from %d to %d", v, math.MinInt32, math.MaxInt32)
}
return U32(uint32(int32(n))), nil
case TypeBool:
switch strings.ToLower(v) {
case "true", "yes", "on", "1":
return []byte{1}, nil
case "false", "no", "off", "0":
return []byte{0}, nil
}
return nil, fmt.Errorf("%q is not true or false", v)
case TypeDomains:
items := splitList(v)
if len(items) == 0 {
return nil, errors.New("give at least one domain")
}
return encodeNames(items)
case TypeRoutes:
return encodeRoutes(v)
case TypeHex:
s := strings.NewReplacer(":", "", " ", "", "-", "").Replace(strings.TrimPrefix(strings.ToLower(v), "0x"))
b, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("%q is not hex bytes (write them as 01:02:0a or 01020a)", v)
}
return b, nil
}
return nil, fmt.Errorf("unknown option type %q (use one of %s)", typ, strings.Join(Types, ", "))
}
// Decode turns option bytes into text, the way Encode reads it. Data that
// doesn't fit the type is shown as hex.
func Decode(typ string, b []byte) string {
asHex := func() string {
parts := make([]string, len(b))
for i, c := range b {
parts[i] = fmt.Sprintf("%02x", c)
}
return strings.Join(parts, ":")
}
switch typ {
case TypeIP, TypeIPs:
if len(b) == 0 || len(b)%4 != 0 || typ == TypeIP && len(b) != 4 {
return asHex()
}
var parts []string
for i := 0; i < len(b); i += 4 {
parts = append(parts, addr4(b[i:]).String())
}
return strings.Join(parts, ", ")
case TypeString:
return string(b)
case TypeU8:
if len(b) == 1 {
return strconv.Itoa(int(b[0]))
}
case TypeU16:
if len(b) == 2 {
return strconv.Itoa(int(binary.BigEndian.Uint16(b)))
}
case TypeU32:
if len(b) == 4 {
return strconv.FormatUint(uint64(binary.BigEndian.Uint32(b)), 10)
}
case TypeI32:
if len(b) == 4 {
return strconv.Itoa(int(int32(binary.BigEndian.Uint32(b))))
}
case TypeBool:
if len(b) == 1 {
return strconv.FormatBool(b[0] != 0)
}
case TypeDomains:
if names, err := decodeNames(b); err == nil {
return strings.Join(names, ", ")
}
case TypeRoutes:
if s, err := decodeRoutes(b); err == nil {
return s
}
}
return asHex()
}
// Normalize rewrites a value in its canonical form ("10.0.0.1,10.0.0.2"
// becomes "10.0.0.1, 10.0.0.2"), or reports why it is invalid.
func Normalize(typ, value string) (string, error) {
b, err := Encode(typ, value)
if err != nil {
return "", err
}
if typ == TypeString {
return value, nil
}
return Decode(typ, b), nil
}
func splitList(v string) []string {
var out []string
for _, s := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) {
if s != "" {
out = append(out, s)
}
}
return out
}
func parseIP4(s string) (netip.Addr, error) {
a, err := netip.ParseAddr(strings.TrimSpace(s))
if err != nil || !a.Is4() {
return netip.Addr{}, fmt.Errorf("%q is not an IPv4 address", s)
}
return a, nil
}
// encodeNames writes domain names in DNS wire format, without
// compression (RFC 3397 allows but doesn't require it).
func encodeNames(names []string) ([]byte, error) {
var out []byte
for _, n := range names {
n = strings.TrimSuffix(strings.ToLower(n), ".")
if n == "" {
return nil, errors.New("empty domain name")
}
if len(n) > 253 {
return nil, fmt.Errorf("domain %q is too long", n)
}
for _, l := range strings.Split(n, ".") {
if l == "" || len(l) > 63 {
return nil, fmt.Errorf("domain %q has an empty or over-long label", n)
}
for _, c := range l {
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
return nil, fmt.Errorf("domain %q: %q isn't allowed in a domain name", n, c)
}
}
out = append(out, byte(len(l)))
out = append(out, l...)
}
out = append(out, 0)
}
return out, nil
}
// decodeNames reads DNS wire-format names, following compression
// pointers within the data.
func decodeNames(b []byte) ([]string, error) {
var out []string
for i := 0; i < len(b); {
name, next, err := readName(b, i)
if err != nil {
return nil, err
}
out = append(out, name)
i = next
}
return out, nil
}
func readName(b []byte, i int) (string, int, error) {
var labels []string
next := -1
for hops := 0; ; hops++ {
if i >= len(b) || hops > 128 {
return "", 0, errors.New("malformed domain name")
}
n := int(b[i])
switch {
case n == 0:
if next < 0 {
next = i + 1
}
return strings.Join(labels, "."), next, nil
case n&0xc0 == 0xc0:
if i+1 >= len(b) {
return "", 0, errors.New("malformed compression pointer")
}
if next < 0 {
next = i + 2
}
i = int(binary.BigEndian.Uint16(b[i:]) & 0x3fff)
default:
if i+1+n > len(b) {
return "", 0, errors.New("domain label runs past the end")
}
labels = append(labels, string(b[i+1:i+1+n]))
i += 1 + n
}
}
}
// encodeRoutes writes classless static routes (RFC 3442): for each, the
// prefix length, the significant octets of the destination, the router.
func encodeRoutes(v string) ([]byte, error) {
var out []byte
items := strings.Split(v, ",")
n := 0
for _, it := range items {
it = strings.TrimSpace(it)
if it == "" {
continue
}
var dst, gw string
if a, b, ok := strings.Cut(it, " via "); ok {
dst, gw = a, b
} else if a, b, ok := strings.Cut(it, " - "); ok { // Kea's form
dst, gw = a, b
} else {
return nil, fmt.Errorf("route %q: write it as DESTINATION/LENGTH via ROUTER", it)
}
pfx, err := netip.ParsePrefix(strings.TrimSpace(dst))
if err != nil || !pfx.Addr().Is4() {
return nil, fmt.Errorf("route %q: %q is not an IPv4 prefix", it, dst)
}
if pfx.Masked() != pfx {
return nil, fmt.Errorf("route %q: %s has host bits set (the network is %s)", it, pfx, pfx.Masked())
}
router, err := parseIP4(gw)
if err != nil {
return nil, fmt.Errorf("route %q: %v", it, err)
}
a := pfx.Addr().As4()
out = append(out, byte(pfx.Bits()))
out = append(out, a[:(pfx.Bits()+7)/8]...)
out = append(out, IP(router)...)
n++
}
if n == 0 {
return nil, errors.New("give at least one route")
}
return out, nil
}
func decodeRoutes(b []byte) (string, error) {
var parts []string
for i := 0; i < len(b); {
bits := int(b[i])
if bits > 32 {
return "", errors.New("bad prefix length")
}
n := (bits + 7) / 8
if i+1+n+4 > len(b) {
return "", errors.New("route runs past the end")
}
var a [4]byte
copy(a[:], b[i+1:i+1+n])
pfx := netip.PrefixFrom(netip.AddrFrom4(a), bits)
parts = append(parts, pfx.String()+" via "+addr4(b[i+1+n:]).String())
i += 1 + n + 4
}
return strings.Join(parts, ", "), nil
}
+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)
}
}
}
+413
View File
@@ -0,0 +1,413 @@
// Package dhcp4 reads and writes DHCPv4 messages (RFC 2131) and their
// options (RFC 2132), and holds the catalog of options Steward knows by
// name. It does no I/O.
package dhcp4
import (
"encoding/binary"
"errors"
"fmt"
"net"
"net/netip"
"strings"
)
// Ports.
const (
ServerPort = 67
ClientPort = 68
)
// Op codes.
const (
BootRequest = 1
BootReply = 2
)
// MessageType is option 53.
type MessageType byte
// Message types (RFC 2132 section 9.6).
const (
Discover MessageType = 1
Offer MessageType = 2
Request MessageType = 3
Decline MessageType = 4
Ack MessageType = 5
Nak MessageType = 6
Release MessageType = 7
Inform MessageType = 8
)
func (t MessageType) String() string {
switch t {
case Discover:
return "DHCPDISCOVER"
case Offer:
return "DHCPOFFER"
case Request:
return "DHCPREQUEST"
case Decline:
return "DHCPDECLINE"
case Ack:
return "DHCPACK"
case Nak:
return "DHCPNAK"
case Release:
return "DHCPRELEASE"
case Inform:
return "DHCPINFORM"
}
return fmt.Sprintf("DHCP type %d", byte(t))
}
// Option codes the server itself uses.
const (
OptPad = 0
OptSubnetMask = 1
OptRouters = 3
OptDNSServers = 6
OptHostName = 12
OptDomainName = 15
OptBroadcast = 28
OptRequestedIP = 50
OptLeaseTime = 51
OptOverload = 52
OptMessageType = 53
OptServerID = 54
OptParamRequest = 55
OptMessage = 56
OptMaxMessage = 57
OptRenewalTime = 58
OptRebindingTime = 59
OptVendorClass = 60
OptClientID = 61
OptClientFQDN = 81
OptEnd = 255
)
// FlagBroadcast is the broadcast bit of the flags field: the client can't
// receive unicast before it has an address.
const FlagBroadcast = 0x8000
var magicCookie = []byte{99, 130, 83, 99}
// headerLen is the fixed part of a message, up to the magic cookie.
const headerLen = 236
// MinLen is the shortest message to send: BOOTP's 300 bytes, which some
// old clients and relays insist on.
const MinLen = 300
// Option is one option: its code and its data.
type Option struct {
Code byte
Data []byte
}
// Packet is a DHCPv4 message.
type Packet struct {
Op byte
HType byte
HLen byte
Hops byte
XID uint32
Secs uint16
Flags uint16
CIAddr netip.Addr
YIAddr netip.Addr
SIAddr netip.Addr
GIAddr netip.Addr
CHAddr [16]byte
SName string
File string
// Options in the order they were read or are to be written. Options
// that appear more than once are joined (RFC 3396).
Options []Option
}
var zero4 = netip.AddrFrom4([4]byte{})
// Parse reads a message.
func Parse(b []byte) (*Packet, error) {
if len(b) < headerLen+4 {
return nil, fmt.Errorf("message too short (%d bytes)", len(b))
}
p := &Packet{
Op: b[0], HType: b[1], HLen: b[2], Hops: b[3],
XID: binary.BigEndian.Uint32(b[4:8]),
Secs: binary.BigEndian.Uint16(b[8:10]),
Flags: binary.BigEndian.Uint16(b[10:12]),
CIAddr: addr4(b[12:16]), YIAddr: addr4(b[16:20]), SIAddr: addr4(b[20:24]), GIAddr: addr4(b[24:28]),
}
copy(p.CHAddr[:], b[28:44])
if p.HLen > 16 {
return nil, fmt.Errorf("hardware address length %d is over 16", p.HLen)
}
if string(b[236:240]) != string(magicCookie) {
return nil, errors.New("no DHCP magic cookie (a BOOTP message?)")
}
opts, err := parseOptions(b[240:])
if err != nil {
return nil, err
}
// Option overload (52): more options in the file and sname fields.
sname, file := b[44:108], b[108:236]
if ov := find(opts, OptOverload); len(ov) == 1 {
if ov[0]&1 != 0 {
more, err := parseOptions(file)
if err != nil {
return nil, fmt.Errorf("options in the file field: %w", err)
}
opts = append(opts, more...)
file = nil
}
if ov[0]&2 != 0 {
more, err := parseOptions(sname)
if err != nil {
return nil, fmt.Errorf("options in the sname field: %w", err)
}
opts = append(opts, more...)
sname = nil
}
}
p.SName, p.File = cString(sname), cString(file)
p.Options = join(opts)
return p, nil
}
func addr4(b []byte) netip.Addr { return netip.AddrFrom4([4]byte(b[:4])) }
func cString(b []byte) string {
if i := strings.IndexByte(string(b), 0); i >= 0 {
b = b[:i]
}
return string(b)
}
func parseOptions(b []byte) ([]Option, error) {
var out []Option
for i := 0; i < len(b); {
code := b[i]
i++
switch code {
case OptPad:
continue
case OptEnd:
return out, nil
}
if i >= len(b) {
return nil, fmt.Errorf("option %d has no length", code)
}
n := int(b[i])
i++
if i+n > len(b) {
return nil, fmt.Errorf("option %d is cut short (%d of %d bytes)", code, len(b)-i, n)
}
out = append(out, Option{Code: code, Data: append([]byte(nil), b[i:i+n]...)})
i += n
}
// Some clients leave out the end option.
return out, nil
}
// join concatenates repeated options into one (RFC 3396), keeping the
// position of the first.
func join(opts []Option) []Option {
var out []Option
seen := map[byte]int{}
for _, o := range opts {
if i, ok := seen[o.Code]; ok {
out[i].Data = append(out[i].Data, o.Data...)
continue
}
seen[o.Code] = len(out)
out = append(out, o)
}
return out
}
func find(opts []Option, code byte) []byte {
for _, o := range opts {
if o.Code == code {
return o.Data
}
}
return nil
}
// Marshal writes the message. Options longer than 255 bytes are split
// (RFC 3396); the message is padded to MinLen.
func (p *Packet) Marshal() []byte {
b := make([]byte, headerLen, MinLen+64)
b[0], b[1], b[2], b[3] = p.Op, p.HType, p.HLen, p.Hops
binary.BigEndian.PutUint32(b[4:8], p.XID)
binary.BigEndian.PutUint16(b[8:10], p.Secs)
binary.BigEndian.PutUint16(b[10:12], p.Flags)
put4(b[12:16], p.CIAddr)
put4(b[16:20], p.YIAddr)
put4(b[20:24], p.SIAddr)
put4(b[24:28], p.GIAddr)
copy(b[28:44], p.CHAddr[:])
copy(b[44:107], p.SName)
copy(b[108:235], p.File)
b = append(b, magicCookie...)
for _, o := range p.Options {
data := o.Data
for first := true; first || len(data) > 0; first = false {
n := min(len(data), 255)
b = append(b, o.Code, byte(n))
b = append(b, data[:n]...)
data = data[n:]
}
}
b = append(b, OptEnd)
for len(b) < MinLen {
b = append(b, OptPad)
}
return b
}
// Len is the length Marshal would produce.
func (p *Packet) Len() int {
n := headerLen + 4 + 1
for _, o := range p.Options {
n += len(o.Data) + 2*max(1, (len(o.Data)+254)/255)
}
return max(n, MinLen)
}
func put4(b []byte, a netip.Addr) {
if a.Is4() {
v := a.As4()
copy(b, v[:])
}
}
// Get returns an option's data (nil when absent).
func (p *Packet) Get(code byte) []byte { return find(p.Options, code) }
// Has reports whether the option is present.
func (p *Packet) Has(code byte) bool {
for _, o := range p.Options {
if o.Code == code {
return true
}
}
return false
}
// Set adds or replaces an option.
func (p *Packet) Set(code byte, data []byte) {
for i, o := range p.Options {
if o.Code == code {
p.Options[i].Data = data
return
}
}
p.Options = append(p.Options, Option{Code: code, Data: data})
}
// Type is the message type (0 when the option is missing or malformed).
func (p *Packet) Type() MessageType {
if d := p.Get(OptMessageType); len(d) == 1 {
return MessageType(d[0])
}
return 0
}
// IPOption returns an option holding one IPv4 address.
func (p *Packet) IPOption(code byte) (netip.Addr, bool) {
d := p.Get(code)
if len(d) != 4 {
return netip.Addr{}, false
}
return addr4(d), true
}
// MAC is the client's hardware address, for Ethernet-like types.
func (p *Packet) MAC() net.HardwareAddr {
n := int(p.HLen)
if n == 0 || n > 16 {
return nil
}
return net.HardwareAddr(append([]byte(nil), p.CHAddr[:n]...))
}
// ClientID is option 61 in lower-case hex ("" when absent).
func (p *Packet) ClientID() string {
if d := p.Get(OptClientID); len(d) > 0 {
return fmt.Sprintf("%x", d)
}
return ""
}
// HostName is the name the client gives: option 12, or else the name in
// the client FQDN option (81), without its domain.
func (p *Packet) HostName() string {
if d := p.Get(OptHostName); len(d) > 0 {
return strings.TrimRight(string(d), "\x00")
}
if d := p.Get(OptClientFQDN); len(d) > 3 {
name := d[3:]
if d[0]&0x04 != 0 { // E bit: canonical wire format
if labels, err := decodeNames(name); err == nil && len(labels) > 0 {
return strings.SplitN(labels[0], ".", 2)[0]
}
return ""
}
return strings.SplitN(strings.TrimRight(string(name), "\x00"), ".", 2)[0]
}
return ""
}
// ParamRequest is the client's parameter request list (option 55).
func (p *Packet) ParamRequest() []byte { return p.Get(OptParamRequest) }
// MaxMessageSize is the largest reply the client accepts (option 57),
// or 576 (RFC 2131's minimum) when it doesn't say.
func (p *Packet) MaxMessageSize() int {
if d := p.Get(OptMaxMessage); len(d) == 2 {
if n := int(binary.BigEndian.Uint16(d)); n >= 576 {
return n
}
}
return 576
}
// Broadcast reports whether the client asked for broadcast replies.
func (p *Packet) Broadcast() bool { return p.Flags&FlagBroadcast != 0 }
// Reply starts a reply to a client's message: the same transaction,
// hardware address, flags and relay address, and the message type.
func (p *Packet) Reply(t MessageType) *Packet {
r := &Packet{
Op: BootReply, HType: p.HType, HLen: p.HLen, XID: p.XID, Flags: p.Flags,
CIAddr: zero4, YIAddr: zero4, SIAddr: zero4, GIAddr: p.GIAddr, CHAddr: p.CHAddr,
}
if !r.GIAddr.IsValid() {
r.GIAddr = zero4
}
r.Set(OptMessageType, []byte{byte(t)})
return r
}
// NewRequest starts a client message (for tests and the CLI's probe).
func NewRequest(t MessageType, mac net.HardwareAddr, xid uint32) *Packet {
p := &Packet{Op: BootRequest, HType: 1, HLen: byte(len(mac)), XID: xid, CIAddr: zero4, YIAddr: zero4, SIAddr: zero4, GIAddr: zero4}
copy(p.CHAddr[:], mac)
p.Set(OptMessageType, []byte{byte(t)})
return p
}
// IsZero reports whether a is unset or 0.0.0.0.
func IsZero(a netip.Addr) bool { return !a.IsValid() || a == zero4 }
// U32 encodes a 32-bit option value.
func U32(v uint32) []byte { return binary.BigEndian.AppendUint32(nil, v) }
// IP encodes an address option value.
func IP(a netip.Addr) []byte {
v := a.As4()
return v[:]
}