Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3c0808591f
111 files changed
+17851
No files matched your search
@@ -0,0 +1,336 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user