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

445 lines
14 KiB
Go

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
}