Files
steward/internal/validate/validate.go
T
abrown38andClaude Opus 5.5 4fd72998ec Reservation lease time; reservations by MAC address or client ID
- A reservation can have its own lease time (--lease-time, the
  reservation dialog), over the subnet's. Schema migration 6.
- A reservation with a MAC address and a client ID matches either one
  (a dual-boot machine sends two client IDs from one MAC); before, it
  was found by MAC and then refused. A lease of the reserved address by
  the client's other identity counts as its own.
- The Leases page shows a client's client ID; reserving a lease uses
  both, or one with --by / the dialog's choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:26:24 +01:00

359 lines
11 KiB
Go

// Package validate checks a whole configuration. Every problem has a
// stable code (docs/validation.md); errors stop a change from being saved,
// warnings are reported with it.
package validate
import (
"fmt"
"net/netip"
"sort"
"strings"
"steward/internal/dhcp4"
"steward/internal/model"
)
// Severities.
const (
Error = "error"
Warning = "warning"
)
// Problem is one finding.
type Problem struct {
Severity string `json:"severity"`
Code string `json:"code"`
Message string `json:"message"`
// Object names what it is about: "subnet office (10.0.10.0/24)".
Object string `json:"object,omitempty"`
}
// Env describes the host, for checks against its interfaces. A nil Env
// skips them (tests, imports for another machine).
type Env struct {
// Interfaces maps each interface name to its IPv4 prefixes
// (10.0.10.1/24).
Interfaces map[string][]netip.Prefix
}
// HasErrors reports whether any problem is an error.
func HasErrors(ps []Problem) bool {
for _, p := range ps {
if p.Severity == Error {
return true
}
}
return false
}
// Errors returns only the errors.
func Errors(ps []Problem) []Problem {
var out []Problem
for _, p := range ps {
if p.Severity == Error {
out = append(out, p)
}
}
return out
}
type checker struct {
out []Problem
}
func (c *checker) add(sev, code, object, format string, args ...any) {
c.out = append(c.out, Problem{Severity: sev, Code: code, Object: object, Message: fmt.Sprintf(format, args...)})
}
// Check validates a configuration.
func Check(st *model.State, env *Env) []Problem {
c := &checker{}
c.settings(st.Settings)
c.subnets(st, env)
c.reservations(st, env)
return c.out
}
func (c *checker) settings(s model.Settings) {
if s.LeaseTime != 0 {
c.leaseTime(s.LeaseTime, "settings")
}
if s.PingTimeout != 0 && (s.PingTimeout < 50 || s.PingTimeout > 5000) {
c.add(Error, "ping_timeout_range", "settings", "the ping timeout is %d ms; use 50 to 5000 ms", s.PingTimeout)
}
if s.DeclineHold != 0 && (s.DeclineHold < 60 || s.DeclineHold > 30*24*3600) {
c.add(Error, "decline_hold_range", "settings", "the decline hold is %d seconds; use 1 minute to 30 days", s.DeclineHold)
}
if !model.ValidAddressOrder(s.AddressOrder) {
c.add(Error, "address_order", "settings", "the address order %q is not one of %s", s.AddressOrder, strings.Join(model.AddressOrders, ", "))
}
c.options(s.Options, "settings")
}
func (c *checker) leaseTime(sec int, object string) {
if sec < model.MinLeaseTime || sec > model.MaxLeaseTime {
c.add(Error, "lease_time_range", object, "the lease time is %d seconds; use 1 minute to 365 days", sec)
}
}
// options checks option values: known types, valid values, no reserved
// codes, no code twice.
func (c *checker) options(opts []model.OptionValue, object string) {
seen := map[int]bool{}
for _, o := range opts {
if o.Code < 1 || o.Code > 254 {
c.add(Error, "option_code", object, "option code %d is out of range (1 to 254)", o.Code)
continue
}
def := dhcp4.ByCode(byte(o.Code))
if why, ok := dhcp4.Reserved[byte(o.Code)]; ok {
c.add(Error, "option_reserved", object, "option %d can't be set: %s", o.Code, why)
continue
}
if seen[o.Code] {
c.add(Error, "option_duplicate", object, "option %s is set twice", def.Name)
}
seen[o.Code] = true
typ := def.Type
if !dhcp4.Known(byte(o.Code)) && o.Type != "" {
typ = o.Type
}
if _, err := dhcp4.Encode(typ, o.Value); err != nil {
c.add(Error, "option_invalid", object, "option %s: %v", def.Name, err)
}
}
}
func (c *checker) subnets(st *model.State, env *Env) {
names := map[string]*model.Subnet{}
for i, s := range st.Subnets {
obj := "subnet " + s.Label()
p := s.Prefix
switch {
case !p.IsValid() || !p.Addr().Is4():
c.add(Error, "subnet_prefix", obj, "the prefix must be an IPv4 network such as 10.0.10.0/24")
continue
case p.Masked() != p:
c.add(Error, "subnet_prefix", obj, "%s has host bits set; the network is %s", p, p.Masked())
continue
case p.Bits() < 8 || p.Bits() > 30:
c.add(Error, "subnet_size", obj, "a /%d is not a subnet DHCP can serve; use /8 to /30", p.Bits())
continue
}
if s.Name != "" {
if strings.ContainsAny(s.Name, " \t/,") {
c.add(Error, "subnet_name", obj, "the name %q has spaces, slashes or commas; use letters, digits, '-' and '_'", s.Name)
}
key := strings.ToLower(s.Name)
if o := names[key]; o != nil {
c.add(Error, "subnet_name_taken", obj, "the name %s is already used by subnet %s", s.Name, o.Prefix)
}
names[key] = s
}
if s.Interface == "" {
c.add(Error, "subnet_interface_missing", obj, "the subnet needs the network interface it is served on (eth0, eth0.10, br-lan)")
} else if !validIfName(s.Interface) {
c.add(Error, "subnet_interface_name", obj, "%q isn't an interface name", s.Interface)
}
for _, o := range st.Subnets[:i] {
if o.Prefix.IsValid() && o.Prefix.Overlaps(p) {
c.add(Error, "subnet_overlap", obj, "it overlaps subnet %s", o.Label())
}
}
if s.LeaseTime != 0 {
c.leaseTime(s.LeaseTime, obj)
}
c.pools(s, obj)
c.options(s.Options, obj)
c.routers(s, st.Settings.Options, obj)
if env != nil && !s.Disabled && s.Interface != "" {
c.hostInterface(s, env, obj)
}
}
}
func (c *checker) pools(s *model.Subnet, obj string) {
if len(s.Pools) == 0 && !s.Disabled {
c.add(Warning, "subnet_no_pools", obj, "the subnet has no pools: only clients with a reservation get an address")
}
if s.PoolOrder != "" && s.PoolOrder != model.PoolsListed && s.PoolOrder != model.PoolsRandom {
c.add(Error, "pool_order", obj, "the pool order %q is not listed or random", s.PoolOrder)
}
if !model.ValidAddressOrder(s.AddressOrder) {
c.add(Error, "address_order", obj, "the address order %q is not one of %s", s.AddressOrder, strings.Join(model.AddressOrders, ", "))
}
network, broadcast := s.Prefix.Addr(), model.Last(s.Prefix)
for i, p := range s.Pools {
if !model.ValidAddressOrder(p.Order) {
c.add(Error, "address_order", obj, "pool %s: the address order %q is not one of %s", p.Range(), p.Order, strings.Join(model.AddressOrders, ", "))
}
if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) {
c.add(Error, "pool_invalid", obj, "pool %s is not a range of IPv4 addresses", p.Range())
continue
}
if !s.Prefix.Contains(p.Start) || !s.Prefix.Contains(p.End) {
c.add(Error, "pool_outside_subnet", obj, "pool %s is not inside %s", p.Range(), s.Prefix)
continue
}
if p.Contains(network) || p.Contains(broadcast) {
c.add(Error, "pool_network_address", obj, "pool %s includes the network address %s or the broadcast address %s; start it at %s and end it at %s at most", p.Range(), network, broadcast, network.Next(), broadcast.Prev())
}
for _, o := range s.Pools[:i] {
if o.Start.Is4() && o.End.Is4() && !(p.End.Less(o.Start) || o.End.Less(p.Start)) {
c.add(Error, "pool_overlap", obj, "pools %s and %s overlap", o.Range(), p.Range())
}
}
}
}
// routers warns about a gateway outside the subnet: clients can't reach it.
func (c *checker) routers(s *model.Subnet, global []model.OptionValue, obj string) {
val := ""
for _, o := range global {
if o.Code == dhcp4.OptRouters {
val = o.Value
}
}
for _, o := range s.Options {
if o.Code == dhcp4.OptRouters {
val = o.Value
}
}
if val == "" {
return
}
b, err := dhcp4.Encode(dhcp4.TypeIPs, val)
if err != nil {
return
}
for i := 0; i+4 <= len(b); i += 4 {
a := netip.AddrFrom4([4]byte(b[i : i+4]))
if !s.Prefix.Contains(a) {
c.add(Warning, "router_outside_subnet", obj, "the router %s is outside %s, so clients can't reach it", a, s.Prefix)
}
for _, p := range s.Pools {
if p.Contains(a) {
c.add(Warning, "router_in_pool", obj, "the router %s is inside pool %s; it is never handed out while it answers pings, but take it out of the pool", a, p.Range())
}
}
}
}
func (c *checker) hostInterface(s *model.Subnet, env *Env, obj string) {
addrs, ok := env.Interfaces[s.Interface]
if !ok {
var names []string
for n := range env.Interfaces {
names = append(names, n)
}
sort.Strings(names)
c.add(Warning, "interface_unknown", obj, "this server has no interface %s (it has %s); the subnet is served once it appears", s.Interface, strings.Join(names, ", "))
return
}
for _, a := range addrs {
if s.Prefix.Contains(a.Addr()) {
for _, p := range s.Pools {
if p.Contains(a.Addr()) {
c.add(Warning, "pool_includes_server", obj, "pool %s includes this server's own address %s; it is never handed out, but take it out of the pool", p.Range(), a.Addr())
}
}
return
}
}
c.add(Warning, "interface_no_address", obj, "interface %s has no address in %s; give it one (the clients renew their leases with it), or check the interface", s.Interface, s.Prefix)
}
func (c *checker) reservations(st *model.State, env *Env) {
byIP := map[netip.Addr]*model.Reservation{}
type key struct {
subnet int64
id string
}
byClient := map[key]*model.Reservation{}
for _, r := range st.Reservations {
obj := "reservation " + r.Label()
if r.MAC == "" && r.ClientID == "" {
c.add(Error, "reservation_no_client", obj, "a reservation needs the client's MAC address or client identifier")
}
if !r.IP.Is4() {
c.add(Error, "reservation_ip", obj, "the address must be an IPv4 address")
continue
}
s := st.SubnetFor(r.IP)
if s == nil {
c.add(Error, "reservation_no_subnet", obj, "%s is in none of the subnets; add the subnet first", r.IP)
continue
}
if r.IP == s.Prefix.Addr() || r.IP == model.Last(s.Prefix) {
c.add(Error, "reservation_network_address", obj, "%s is the network or broadcast address of %s", r.IP, s.Prefix)
}
// A disabled reservation is ignored, so it takes no address or
// client from another; enabling it again is checked.
if !r.Disabled {
if o := byIP[r.IP]; o != nil {
c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label())
}
byIP[r.IP] = r
// Either of a client's MAC address and client ID finds its
// reservation, so neither may be another's.
for _, k := range []key{{s.ID, "mac " + r.MAC}, {s.ID, "id " + r.ClientID}} {
if k.id == "mac " || k.id == "id " {
continue
}
if o := byClient[k]; o != nil && o != r {
c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label())
break
}
byClient[k] = r
}
}
if r.LeaseTime != 0 {
c.leaseTime(r.LeaseTime, obj)
}
if r.Hostname != "" && !validHostname(r.Hostname) {
c.add(Error, "reservation_hostname", obj, "%q isn't a valid host name (letters, digits and '-', labels up to 63 characters, separated by dots)", r.Hostname)
}
c.options(r.Options, obj)
if env != nil && s.Interface != "" {
for _, a := range env.Interfaces[s.Interface] {
if a.Addr() == r.IP {
c.add(Warning, "reservation_server_address", obj, "%s is this server's own address on %s", r.IP, s.Interface)
}
}
}
}
}
func validIfName(n string) bool {
if n == "" || len(n) > 15 {
return false
}
for _, c := range n {
if c <= ' ' || c == '/' || c == ':' || c > '~' {
return false
}
}
return true
}
func validHostname(n string) bool {
n = strings.TrimSuffix(n, ".")
if n == "" || len(n) > 253 {
return false
}
for _, l := range strings.Split(n, ".") {
if l == "" || len(l) > 63 || l[0] == '-' || l[len(l)-1] == '-' {
return false
}
for _, c := range l {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
return false
}
}
}
return true
}
// ValidHostname reports whether n can be sent as a host name.
func ValidHostname(n string) bool { return validHostname(n) }