// 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) } 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") } network, broadcast := s.Prefix.Addr(), model.Last(s.Prefix) for i, p := range s.Pools { 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) 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, 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, 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, p) } } } } // 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) } } } } 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, 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) } 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 k := key{s.ID, "mac " + r.MAC} if r.ClientID != "" { k.id = "id " + r.ClientID } if o := byClient[k]; o != nil { c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label()) } byClient[k] = r 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) }