Files
steward/internal/dhcpd/server.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

404 lines
10 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"
"math/rand/v2"
"net"
"net/netip"
"sort"
"sync"
"sync/atomic"
"time"
"steward/internal/dhcp4"
"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)
SetLastSeen(ctx context.Context, seen map[netip.Addr]time.Time) 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, and
// clients' last-seen times).
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
// randN returns a random number in [0, n) (address order random).
randN func(n int) int
// 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, randN: rand.IntN, 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"`
// ID numbers the events since stewardd started.
ID uint64 `json:"id"`
// Direction is "in" for a client's message, "out" for Steward's
// answer, "" for an event of Steward's own (CONFLICT, NO ADDRESS).
Direction string `json:"direction,omitempty"`
ClientID string `json:"client_id,omitempty"`
// SubnetID is the subnet that refused a client (DENIED).
SubnetID int64 `json:"subnet_id,omitempty"`
// Fields and Options are the message, in words (GET /events/{id}, or
// ?details=1).
Fields []dhcp4.Field `json:"fields,omitempty"`
Options []dhcp4.Field `json:"options,omitempty"`
}
type events struct {
mu sync.Mutex
max int
list []Event
counts map[string]uint64
lastID uint64
}
func (e *events) add(ev Event) {
e.mu.Lock()
defer e.mu.Unlock()
e.lastID++
ev.ID = e.lastID
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
// keep accepts.
func (s *Server) Events(limit int, keep func(Event) bool) []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 keep != nil && !keep(ev) {
continue
}
out = append(out, ev)
}
return out
}
// Event returns one event, with its details.
func (s *Server) Event(id uint64) (Event, bool) {
s.events.mu.Lock()
defer s.events.mu.Unlock()
for i := len(s.events.list) - 1; i >= 0; i-- {
if s.events.list[i].ID == id {
return s.events.list[i], true
}
}
return Event{}, false
}
// 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
}