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

414 lines
9.9 KiB
Go

// 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[:]
}