// Package model holds Steward's data: the configuration (State), leases, // users and tokens, the history of changes and the audit log. package model import ( "encoding/json" "fmt" "net" "net/netip" "sort" "strings" "time" ) // Defaults for settings that are 0 or unset. const ( DefaultLeaseTime = 12 * 60 * 60 // seconds DefaultPingTimeout = 500 // milliseconds DefaultDeclineHold = 24 * 60 * 60 // seconds MinLeaseTime = 60 MaxLeaseTime = 365 * 24 * 60 * 60 ) // State is the whole configuration. It is small and changed as a whole // (internal/engine). type State struct { Settings Settings `json:"settings"` Subnets []*Subnet `json:"subnets"` Reservations []*Reservation `json:"reservations"` } // Settings are the server-wide settings and the options every subnet // starts from. type Settings struct { // LeaseTime is the default lease time in seconds (0: DefaultLeaseTime). LeaseTime int `json:"lease_time"` // Options apply to every subnet; a subnet's or a reservation's own // options of the same code win. Options []OptionValue `json:"options"` // PingCheck pings an address before offering it, and skips it if // something answers. PingTimeout is how long to wait, in ms. PingCheck bool `json:"ping_check"` PingTimeout int `json:"ping_timeout_ms"` // DeclineHold is how long an address a client declined (or that // answered a ping) is held back, in seconds. DeclineHold int `json:"decline_hold"` // NotAuthoritative stops Steward from refusing (DHCPNAK) requests for // addresses that don't belong on the interface, for networks where // another DHCP server shares the segment. Off by default. NotAuthoritative bool `json:"not_authoritative"` } // DefaultSettings are the settings of a new database. func DefaultSettings() Settings { return Settings{LeaseTime: DefaultLeaseTime, PingCheck: true, PingTimeout: DefaultPingTimeout, DeclineHold: DefaultDeclineHold, Options: []OptionValue{}} } // EffectiveLeaseTime is the lease time with its default. func (s Settings) EffectiveLeaseTime() int { if s.LeaseTime > 0 { return s.LeaseTime } return DefaultLeaseTime } // EffectivePingTimeout is the ping timeout with its default. func (s Settings) EffectivePingTimeout() time.Duration { ms := s.PingTimeout if ms <= 0 { ms = DefaultPingTimeout } return time.Duration(ms) * time.Millisecond } // EffectiveDeclineHold is the decline hold with its default. func (s Settings) EffectiveDeclineHold() time.Duration { sec := s.DeclineHold if sec <= 0 { sec = DefaultDeclineHold } return time.Duration(sec) * time.Second } // OptionValue is an option set in the configuration: its code and its // value as text ("10.0.10.1, 10.0.10.2"). Name is the catalog's name, // filled in for display. Type is only stored for options the catalog // doesn't know (default hex). type OptionValue struct { Code int `json:"code"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Value string `json:"value"` } // Subnet is an IPv4 network served on one interface. type Subnet struct { ID int64 `json:"id"` // Name is a label for people ("office", "iot"); optional, unique. Name string `json:"name"` // Interface is the network interface the subnet is served on. Interface string `json:"interface"` Prefix netip.Prefix `json:"prefix"` Pools []Pool `json:"pools"` // LeaseTime overrides the settings' lease time (0: inherit). LeaseTime int `json:"lease_time"` Options []OptionValue `json:"options"` // Disabled subnets keep their configuration and leases but get no // answers. Disabled bool `json:"disabled"` Comment string `json:"comment"` } // Label names the subnet in messages: "office (10.0.10.0/24)". func (s *Subnet) Label() string { if s.Name != "" { return s.Name + " (" + s.Prefix.String() + ")" } return s.Prefix.String() } // Pool is a range of addresses handed out to any client. type Pool struct { Start netip.Addr `json:"start"` End netip.Addr `json:"end"` } func (p Pool) String() string { return p.Start.String() + "-" + p.End.String() } // Contains reports whether a is in the pool. func (p Pool) Contains(a netip.Addr) bool { return a.Is4() && p.Start.Compare(a) <= 0 && a.Compare(p.End) <= 0 } // Size is the number of addresses in the pool. func (p Pool) Size() int { if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) { return 0 } return int(U32(p.End)-U32(p.Start)) + 1 } // ParsePool reads "10.0.10.100-10.0.10.199" (spaces around the dash are // fine) or a prefix, "10.0.10.128/25". func ParsePool(s string) (Pool, error) { s = strings.TrimSpace(s) if a, b, ok := strings.Cut(s, "-"); ok { start, err1 := netip.ParseAddr(strings.TrimSpace(a)) end, err2 := netip.ParseAddr(strings.TrimSpace(b)) if err1 != nil || err2 != nil || !start.Is4() || !end.Is4() { return Pool{}, fmt.Errorf("pool %q: write it as FIRST-LAST, with IPv4 addresses", s) } if end.Less(start) { return Pool{}, fmt.Errorf("pool %q ends before it starts", s) } return Pool{Start: start, End: end}, nil } pfx, err := netip.ParsePrefix(s) if err != nil || !pfx.Addr().Is4() { return Pool{}, fmt.Errorf("pool %q: write it as FIRST-LAST (10.0.10.100-10.0.10.199) or as a prefix", s) } pfx = pfx.Masked() return Pool{Start: pfx.Addr(), End: Last(pfx)}, nil } // U32 is an IPv4 address as a number. func U32(a netip.Addr) uint32 { b := a.As4() return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) } // FromU32 is the address of a number. func FromU32(v uint32) netip.Addr { return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)}) } // Last is the last address of a prefix (its broadcast address). func Last(p netip.Prefix) netip.Addr { p = p.Masked() return FromU32(U32(p.Addr()) | (1<<(32-p.Bits()) - 1)) } // Mask is the prefix's netmask. func Mask(p netip.Prefix) netip.Addr { return FromU32(^uint32(0) << (32 - p.Bits())) } // Reservation gives a client a fixed address. The client is known by its // MAC address, or by its client identifier (option 61) when ClientID is // set. The subnet is the one whose prefix holds the address. type Reservation struct { ID int64 `json:"id"` MAC string `json:"mac,omitempty"` ClientID string `json:"client_id,omitempty"` IP netip.Addr `json:"ip"` // Hostname is sent to the client (option 12). Hostname string `json:"hostname"` Options []OptionValue `json:"options"` Comment string `json:"comment"` } // Label names the reservation in messages. func (r *Reservation) Label() string { who := r.MAC if r.ClientID != "" { who = "client id " + r.ClientID } if r.Hostname != "" { return r.Hostname + " (" + r.IP.String() + ", " + who + ")" } return r.IP.String() + " (" + who + ")" } // NormalizeMAC writes a MAC address as aa:bb:cc:dd:ee:ff. It accepts // colons, dashes, dots (Cisco) or no separators. func NormalizeMAC(s string) (string, error) { s = strings.TrimSpace(s) if len(s) == 12 && !strings.ContainsAny(s, ":-.") { var parts []string for i := 0; i < 12; i += 2 { parts = append(parts, s[i:i+2]) } s = strings.Join(parts, ":") } hw, err := net.ParseMAC(s) if err != nil || len(hw) != 6 { return "", fmt.Errorf("%q is not a MAC address (write it as aa:bb:cc:dd:ee:ff)", s) } return hw.String(), nil } // NormalizeClientID writes a client identifier as lower-case hex without // separators. It accepts hex with colons or dashes. func NormalizeClientID(s string) (string, error) { h := strings.ToLower(strings.NewReplacer(":", "", "-", "", " ", "").Replace(strings.TrimSpace(s))) if h == "" || len(h)%2 != 0 || strings.Trim(h, "0123456789abcdef") != "" { return "", fmt.Errorf("%q is not a client identifier in hex (01:aa:bb:cc:dd:ee:ff)", s) } return h, nil } // SubnetByID finds a subnet. func (st *State) SubnetByID(id int64) *Subnet { for _, s := range st.Subnets { if s.ID == id { return s } } return nil } // SubnetFor returns the subnet whose prefix holds a (the first, in // order), or nil. func (st *State) SubnetFor(a netip.Addr) *Subnet { for _, s := range st.Subnets { if s.Prefix.Contains(a) { return s } } return nil } // ReservationByID finds a reservation. func (st *State) ReservationByID(id int64) *Reservation { for _, r := range st.Reservations { if r.ID == id { return r } } return nil } // FindSubnet finds a subnet by ID, name or prefix. func (st *State) FindSubnet(ref string) *Subnet { ref = strings.TrimSpace(ref) var id int64 if _, err := fmt.Sscan(ref, &id); err == nil && fmt.Sprint(id) == ref { if s := st.SubnetByID(id); s != nil { return s } } for _, s := range st.Subnets { if strings.EqualFold(s.Name, ref) && s.Name != "" { return s } } if p, err := netip.ParsePrefix(ref); err == nil { for _, s := range st.Subnets { if s.Prefix == p.Masked() { return s } } } return nil } // Clone returns a deep copy. func (st *State) Clone() *State { b, err := json.Marshal(st) if err != nil { panic(err) } var out State if err := json.Unmarshal(b, &out); err != nil { panic(err) } return &out } // Sort puts subnets and reservations in display order: subnets by // interface then address, reservations by address. func (st *State) Sort() { sort.SliceStable(st.Subnets, func(i, j int) bool { a, b := st.Subnets[i], st.Subnets[j] if a.Interface != b.Interface { return a.Interface < b.Interface } return a.Prefix.Addr().Less(b.Prefix.Addr()) }) sort.SliceStable(st.Reservations, func(i, j int) bool { return st.Reservations[i].IP.Less(st.Reservations[j].IP) }) } // Interfaces lists the interfaces subnets are served on (enabled ones). func (st *State) Interfaces() []string { seen := map[string]bool{} var out []string for _, s := range st.Subnets { if !s.Disabled && s.Interface != "" && !seen[s.Interface] { seen[s.Interface] = true out = append(out, s.Interface) } } sort.Strings(out) return out }