Files
abrown38andClaude Opus 5.5 3c0808591f 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>
2026-10-14 12:14:07 +01:00

372 lines
9.0 KiB
Go

// Package dhcpd is Steward's DHCPv4 server: it listens on the interfaces
// that have subnets, answers requests from those subnets only, and keeps
// the leases (docs/design.md, "Answering DHCP").
package dhcpd
import (
"context"
"errors"
"log/slog"
"net"
"net/netip"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"steward/internal/model"
)
// LeaseStore persists leases.
type LeaseStore interface {
LoadLeases(ctx context.Context) ([]*model.Lease, error)
PutLease(ctx context.Context, l *model.Lease) error
DeleteLease(ctx context.Context, ip netip.Addr) error
PruneLeases(ctx context.Context, before time.Time) (int64, error)
}
// Interfaces reports a network interface's IPv4 addresses with their
// prefix lengths (10.0.10.1/24); an error when it doesn't exist.
type Interfaces interface {
Addrs(name string) ([]netip.Prefix, error)
}
// Pinger checks whether an address answers (conflict detection).
type Pinger interface {
Ping(ctx context.Context, a netip.Addr, timeout time.Duration) bool
}
// How long an offer holds its address for the client.
const offerHold = time.Minute
// Leases that ended this long ago are deleted (until then, a client that
// comes back gets its old address if it is free).
const keepEnded = 30 * 24 * time.Hour
// Server answers DHCP requests.
type Server struct {
log *slog.Logger
store LeaseStore
ifaces Interfaces
pinger Pinger
now func() time.Time
// ServerPort and ClientPort are 67 and 68 (tests use others).
ServerPort, ClientPort int
cfg atomic.Pointer[config]
mu sync.Mutex
leases map[netip.Addr]*model.Lease
offers map[netip.Addr]offer
events events
warned sync.Map // throttled log lines: key -> time.Time
listen listeners
changed chan struct{}
}
type offer struct {
cl client
expires time.Time
}
// New makes a server. ifaces and pinger may be nil (the host's interfaces;
// no ping check).
func New(log *slog.Logger, st LeaseStore, ifaces Interfaces, pinger Pinger) *Server {
if log == nil {
log = slog.Default()
}
if ifaces == nil {
ifaces = &HostInterfaces{}
}
s := &Server{log: log, store: st, ifaces: ifaces, pinger: pinger, now: time.Now, ServerPort: 67, ClientPort: 68,
leases: map[netip.Addr]*model.Lease{}, offers: map[netip.Addr]offer{}, changed: make(chan struct{}, 1)}
s.cfg.Store(compile(&model.State{Settings: model.DefaultSettings()}))
s.events.max = 1000
return s
}
// LoadLeases reads the leases from the store (at start).
func (s *Server) LoadLeases(ctx context.Context) error {
ls, err := s.store.LoadLeases(ctx)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
for _, l := range ls {
s.leases[l.IP] = l
}
return nil
}
// SetConfig gives the server a new configuration. Listeners follow it.
func (s *Server) SetConfig(st *model.State) {
s.cfg.Store(compile(st))
select {
case s.changed <- struct{}{}:
default:
}
}
// Leases returns a copy of every lease, by address.
func (s *Server) Leases() []*model.Lease {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]*model.Lease, 0, len(s.leases))
for _, l := range s.leases {
c := *l
out = append(out, &c)
}
sort.Slice(out, func(i, j int) bool { return out[i].IP.Less(out[j].IP) })
return out
}
// Lease returns one lease.
func (s *Server) Lease(ip netip.Addr) *model.Lease {
s.mu.Lock()
defer s.mu.Unlock()
if l := s.leases[ip]; l != nil {
c := *l
return &c
}
return nil
}
// ErrNoLease is returned for an address without a lease.
var ErrNoLease = errors.New("no lease for that address")
// DeleteLease forgets a lease: the address is free at once (the client
// may still be using it until it next renews, and then gets a NAK).
func (s *Server) DeleteLease(ctx context.Context, ip netip.Addr) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.leases[ip] == nil {
return ErrNoLease
}
if err := s.store.DeleteLease(ctx, ip); err != nil {
return err
}
delete(s.leases, ip)
delete(s.offers, ip)
return nil
}
// ImportLeases adds leases (from another server), replacing what is known
// for their addresses.
func (s *Server) ImportLeases(ctx context.Context, ls []*model.Lease) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, l := range ls {
if err := s.store.PutLease(ctx, l); err != nil {
return err
}
c := *l
s.leases[l.IP] = &c
}
return nil
}
// Prune deletes leases that ended long ago, and forgets old offers.
func (s *Server) Prune(ctx context.Context) {
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
cut := now.Add(-keepEnded)
n := 0
for ip, l := range s.leases {
if l.End.Before(cut) {
delete(s.leases, ip)
n++
}
}
for ip, o := range s.offers {
if now.After(o.expires) {
delete(s.offers, ip)
}
}
if n > 0 {
if _, err := s.store.PruneLeases(ctx, cut); err != nil {
s.log.Warn("pruning old leases failed", "err", err)
}
}
}
// putLease saves a lease, then keeps it (s.mu held).
func (s *Server) putLease(ctx context.Context, l *model.Lease) error {
l.Updated = s.now()
if err := s.store.PutLease(ctx, l); err != nil {
return err
}
c := *l
s.leases[l.IP] = &c
return nil
}
// warnOnce logs a warning at most once per interval for a key.
func (s *Server) warnOnce(key string, every time.Duration, msg string, args ...any) {
now := s.now()
if v, ok := s.warned.Load(key); ok && now.Sub(v.(time.Time)) < every {
return
}
s.warned.Store(key, now)
s.log.Warn(msg, args...)
}
// ---- interfaces
// HostInterfaces reads this machine's interfaces (cached briefly, since
// every request asks).
type HostInterfaces struct {
mu sync.Mutex
at time.Time
cache map[string][]netip.Prefix
err error
}
// Addrs implements Interfaces.
func (h *HostInterfaces) Addrs(name string) ([]netip.Prefix, error) {
all, err := h.All()
if err != nil {
return nil, err
}
a, ok := all[name]
if !ok {
return nil, errors.New("no interface " + name)
}
return a, nil
}
// All returns every interface with its IPv4 prefixes.
func (h *HostInterfaces) All() (map[string][]netip.Prefix, error) {
h.mu.Lock()
defer h.mu.Unlock()
if time.Since(h.at) < 5*time.Second && (h.cache != nil || h.err != nil) {
return h.cache, h.err
}
h.at = time.Now()
ifs, err := net.Interfaces()
if err != nil {
h.cache, h.err = nil, err
return nil, err
}
m := map[string][]netip.Prefix{}
for _, i := range ifs {
var ps []netip.Prefix
addrs, _ := i.Addrs()
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok {
if ip4 := n.IP.To4(); ip4 != nil {
bits, _ := n.Mask.Size()
ps = append(ps, netip.PrefixFrom(netip.AddrFrom4([4]byte(ip4)), bits))
}
}
}
m[i.Name] = ps
}
h.cache, h.err = m, nil
return m, nil
}
// serverAddr is the interface's address to answer a subnet's clients
// from: its address in the subnet, else its first IPv4 address.
func (s *Server) serverAddr(iface string, sub *subnet) (netip.Addr, error) {
addrs, err := s.ifaces.Addrs(iface)
if err != nil {
return netip.Addr{}, err
}
for _, a := range addrs {
if sub != nil && sub.Prefix.Contains(a.Addr()) {
return a.Addr(), nil
}
}
if len(addrs) > 0 {
return addrs[0].Addr(), nil
}
return netip.Addr{}, errors.New("interface " + iface + " has no IPv4 address")
}
// ownAddrs is the set of the interface's addresses.
func (s *Server) ownAddrs(iface string) map[netip.Addr]bool {
addrs, _ := s.ifaces.Addrs(iface)
m := make(map[netip.Addr]bool, len(addrs))
for _, p := range addrs {
m[p.Addr()] = true
}
return m
}
// isOwnAddr reports whether a is one of the interface's addresses.
func (s *Server) isOwnAddr(iface string, a netip.Addr) bool {
addrs, _ := s.ifaces.Addrs(iface)
for _, p := range addrs {
if p.Addr() == a {
return true
}
}
return false
}
// ---- events
// Event is one DHCP message in or out, for the activity log.
type Event struct {
Time time.Time `json:"time"`
Interface string `json:"interface"`
Type string `json:"type"` // DHCPDISCOVER, DHCPOFFER, ...
MAC string `json:"mac"`
IP string `json:"ip,omitempty"`
Hostname string `json:"hostname,omitempty"`
Detail string `json:"detail,omitempty"`
}
type events struct {
mu sync.Mutex
max int
list []Event
counts map[string]uint64
}
func (e *events) add(ev Event) {
e.mu.Lock()
defer e.mu.Unlock()
e.list = append(e.list, ev)
if len(e.list) > e.max {
e.list = append([]Event(nil), e.list[len(e.list)-e.max:]...)
}
if e.counts == nil {
e.counts = map[string]uint64{}
}
e.counts[ev.Type]++
}
// Events returns the latest events, newest first, optionally only those of
// one MAC address or interface.
func (s *Server) Events(limit int, mac, iface string) []Event {
s.events.mu.Lock()
defer s.events.mu.Unlock()
out := []Event{}
for i := len(s.events.list) - 1; i >= 0 && (limit <= 0 || len(out) < limit); i-- {
ev := s.events.list[i]
if mac != "" && !strings.EqualFold(ev.MAC, mac) || iface != "" && ev.Interface != iface {
continue
}
out = append(out, ev)
}
return out
}
// Counts returns how many messages of each type were seen and sent since
// the start.
func (s *Server) Counts() map[string]uint64 {
s.events.mu.Lock()
defer s.events.mu.Unlock()
out := map[string]uint64{}
for k, v := range s.events.counts {
out[k] = v
}
return out
}