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)< 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 }