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>
372 lines
9.0 KiB
Go
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
|
|
}
|