Files
steward/internal/model/config.go
T
abrown38andClaude Opus 5.5 3ad2f981d4 Allow and deny lists per subnet; every option of a DHCP message in words
- A subnet is a deny-list subnet (the default: everyone not denied) or
  an allow-list subnet (only clients allowed or with a reservation);
  the deny list counts in both. Entries are MAC addresses, prefixes
  (aa:bb:cc:*) or client IDs. A refused client gets no answer (DENIED
  in the activity log, with an Allow button) or a NAK on renewal.
  steward access, the subnet's Access tab. Schema migration 7.
- The activity log keeps each message's header fields and options,
  decoded into words (dhcp4.Describe): the parameter request list by
  name, client IDs, lease times. ▸ on an Activity row, steward
  activity show N / --details, GET /events/{id}.

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

423 lines
13 KiB
Go

// 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"`
// AddressOrder is which never-used pool address a new client gets:
// sequential (the lowest, the default), reverse (the highest) or
// random. Subnets and pools can set their own.
AddressOrder string `json:"address_order,omitempty"`
}
// Address orders: which never-used pool address a new client gets. When
// every free address has been used before, the one free the longest is
// given out, whatever the order.
const (
OrderSequential = "sequential"
OrderReverse = "reverse"
OrderRandom = "random"
)
// Pool orders: which of a subnet's pools a new address comes from.
const (
// PoolsListed tries the pools in the order they are listed.
PoolsListed = "listed"
// PoolsRandom picks a pool at random, weighted by its never-used
// addresses, so addresses spread over the whole subnet.
PoolsRandom = "random"
)
// AddressOrders are the address orders, for messages and help.
var AddressOrders = []string{OrderSequential, OrderReverse, OrderRandom}
// ValidAddressOrder reports whether s is an address order ("" too: the
// default or inherited one).
func ValidAddressOrder(s string) bool {
return s == "" || s == OrderSequential || s == OrderReverse || s == OrderRandom
}
// EffectiveAddressOrder is the settings' address order with its default.
func (s Settings) EffectiveAddressOrder() string {
if s.AddressOrder == "" {
return OrderSequential
}
return s.AddressOrder
}
// 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"`
// PoolOrder is which pool a new address comes from: listed (the
// default, "") or random.
PoolOrder string `json:"pool_order,omitempty"`
// AddressOrder overrides the settings' address order inside the
// subnet's pools ("": inherit); a pool can override it in turn.
AddressOrder string `json:"address_order,omitempty"`
// LeaseTime overrides the settings' lease time (0: inherit).
LeaseTime int `json:"lease_time"`
Options []OptionValue `json:"options"`
// Access is who the subnet answers: everyone not on Deny ("", a
// deny-list subnet) or, as an allow-list subnet, only clients on
// Allow or with a reservation, and not on Deny (access.go).
Access string `json:"access,omitempty"`
Allow []ClientMatch `json:"allow,omitempty"`
Deny []ClientMatch `json:"deny,omitempty"`
// 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"`
// Order overrides the subnet's address order in this pool ("":
// inherit).
Order string `json:"order,omitempty"`
}
// String is the pool as ParsePool reads it: FIRST-LAST, with :ORDER when
// it has its own.
func (p Pool) String() string {
if p.Order != "" {
return p.Range() + ":" + p.Order
}
return p.Range()
}
// Range is FIRST-LAST.
func (p Pool) Range() string { return p.Start.String() + "-" + p.End.String() }
// SameRange reports whether two pools cover the same addresses.
func (p Pool) SameRange(o Pool) bool { return p.Start == o.Start && p.End == o.End }
// 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", optionally followed by the pool's
// own address order: "10.0.10.100-10.0.10.199:random".
func ParsePool(s string) (Pool, error) {
s = strings.TrimSpace(s)
if r, order, ok := strings.Cut(s, ":"); ok {
order = strings.ToLower(strings.TrimSpace(order))
if order == "default" {
order = "" // the subnet's
} else if order == "" || !ValidAddressOrder(order) {
return Pool{}, fmt.Errorf("pool %q: the order after the colon is %s or default (the subnet's)", s, strings.Join(AddressOrders, ", "))
}
p, err := ParsePool(r)
p.Order = order
return p, err
}
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"`
// The client is known by its MAC address, its client identifier
// (option 61), or either when both are set.
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"`
// LeaseTime overrides the subnet's lease time (0: inherit).
LeaseTime int `json:"lease_time,omitempty"`
// Disabled reservations are kept but ignored: the client is treated
// like any other, and an address inside a pool may go to others.
Disabled bool `json:"disabled"`
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
}