- 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>
423 lines
13 KiB
Go
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
|
|
}
|