- 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>
404 lines
10 KiB
Go
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
|
|
}
|