- Which never-used pool address a new client gets: sequential (the lowest, as before), reverse or random, in the settings, a subnet or a single pool (FIRST-LAST:ORDER). When no never-used address is left, the one free the longest, whatever the order; returning clients keep theirs. - A subnet's pools are used in the order listed, or one at random weighted by its never-used addresses. Pools now keep the order they are given in (no more sorting by address). - CLI --address-order and --pool-order; --add-pool of an existing range changes its order in place. Web UI: per-pool order and a move-up button, pool and address order on the subnet and settings pages. - Kea's allocator: random is imported. Schema migration 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
11 KiB
Go
352 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
|
|
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) }
|