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:
abrown38andClaude Opus 5.5 committed 2026-10-14 12:14:07 +01:00
commit 3c0808591f
111 files changed
+17851

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
package dhcpd
import (
"net/netip"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
)
// config is a configuration prepared for answering requests: subnets by
// interface, reservations indexed, options encoded.
type config struct {
state *model.State
byIface map[string][]*subnet
// resByIP holds every reservation by address.
resByIP map[netip.Addr]*model.Reservation
// resByClient holds reservations per subnet by "mac aa:..." or
// "id 01aa...".
resByClient map[int64]map[string]*model.Reservation
settings model.Settings
}
type subnet struct {
*model.Subnet
network, broadcast netip.Addr
leaseTime time.Duration
// options are the encoded options of the settings and the subnet,
// the subnet's winning.
options map[byte][]byte
}
func compile(st *model.State) *config {
c := &config{
state: st,
byIface: map[string][]*subnet{},
resByIP: map[netip.Addr]*model.Reservation{},
resByClient: map[int64]map[string]*model.Reservation{},
settings: st.Settings,
}
global := encodeOptions(st.Settings.Options, nil)
for _, s := range st.Subnets {
if s.Disabled || s.Interface == "" || !s.Prefix.Addr().Is4() {
continue
}
lt := s.LeaseTime
if lt <= 0 {
lt = st.Settings.EffectiveLeaseTime()
}
sc := &subnet{Subnet: s, network: s.Prefix.Addr(), broadcast: model.Last(s.Prefix), leaseTime: time.Duration(lt) * time.Second,
options: encodeOptions(s.Options, global)}
c.byIface[s.Interface] = append(c.byIface[s.Interface], sc)
}
for _, r := range st.Reservations {
c.resByIP[r.IP] = r
s := st.SubnetFor(r.IP)
if s == nil {
continue
}
if c.resByClient[s.ID] == nil {
c.resByClient[s.ID] = map[string]*model.Reservation{}
}
if r.ClientID != "" {
c.resByClient[s.ID]["id "+r.ClientID] = r
}
if r.MAC != "" {
c.resByClient[s.ID]["mac "+r.MAC] = r
}
}
return c
}
// encodeOptions encodes options over a base set (copied).
func encodeOptions(opts []model.OptionValue, base map[byte][]byte) map[byte][]byte {
out := map[byte][]byte{}
for k, v := range base {
out[k] = v
}
for _, o := range opts {
if o.Code <= 0 || o.Code >= 255 {
continue
}
def := dhcp4.ByCode(byte(o.Code))
typ := def.Type
if !dhcp4.Known(byte(o.Code)) && o.Type != "" {
typ = o.Type
}
if b, err := dhcp4.Encode(typ, o.Value); err == nil {
out[byte(o.Code)] = b
}
}
return out
}
// reservationFor finds the client's reservation in a subnet.
func (c *config) reservationFor(subnetID int64, cl client) *model.Reservation {
m := c.resByClient[subnetID]
if m == nil {
return nil
}
if cl.id != "" {
if r := m["id "+cl.id]; r != nil {
return r
}
}
return m["mac "+cl.mac]
}
// subnetFor finds the interface's subnet holding a.
func (c *config) subnetFor(iface string, a netip.Addr) *subnet {
for _, s := range c.byIface[iface] {
if s.Prefix.Contains(a) {
return s
}
}
return nil
}
// client identifies who sent a request.
type client struct {
mac string // aa:bb:cc:dd:ee:ff
id string // client identifier (option 61) in hex, or ""
}
// matches reports whether a reservation is for the client: by client
// identifier when the reservation has one, else by MAC address.
func (cl client) matches(r *model.Reservation) bool {
if r.ClientID != "" {
return r.ClientID == cl.id
}
return r.MAC == cl.mac
}
func (cl client) String() string {
if cl.id != "" {
return cl.mac + " (client id " + cl.id + ")"
}
return cl.mac
}
+476
View File
@@ -0,0 +1,476 @@
package dhcpd
import (
"context"
"fmt"
"net/netip"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
)
// Outbound is a reply and where to send it.
type Outbound struct {
Packet *dhcp4.Packet
// Dst is the client's address for unicast replies; invalid for a
// broadcast on the interface.
Dst netip.Addr
}
var broadcast = netip.AddrFrom4([4]byte{255, 255, 255, 255})
// Handle answers one request that arrived on an interface. It returns nil
// when there is nothing to send.
func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Outbound {
if p.Op != dhcp4.BootRequest {
return nil
}
if p.HType != 1 || p.HLen != 6 {
s.warnOnce("htype "+iface, time.Hour, "ignoring a DHCP request for a hardware type other than Ethernet", "interface", iface, "htype", p.HType, "hlen", p.HLen)
return nil
}
cfg := s.cfg.Load()
subs := cfg.byIface[iface]
if len(subs) == 0 {
return nil
}
if !dhcp4.IsZero(p.GIAddr) {
s.warnOnce("relay "+iface, time.Hour, "ignoring a relayed DHCP request: Steward 0.1 doesn't serve relays yet", "interface", iface, "relay", p.GIAddr)
return nil
}
cl := client{mac: p.MAC().String(), id: p.ClientID()}
t := p.Type()
ev := Event{Time: s.now(), Interface: iface, Type: t.String(), MAC: cl.mac, Hostname: p.HostName()}
if a, ok := p.IPOption(dhcp4.OptRequestedIP); ok {
ev.IP = a.String()
} else if !dhcp4.IsZero(p.CIAddr) {
ev.IP = p.CIAddr.String()
}
s.events.add(ev)
var out *Outbound
switch t {
case dhcp4.Discover:
out = s.discover(ctx, cfg, iface, subs, p, cl)
case dhcp4.Request:
out = s.request(ctx, cfg, iface, p, cl)
case dhcp4.Decline:
s.decline(ctx, cfg, iface, p, cl)
case dhcp4.Release:
s.release(ctx, iface, p, cl)
case dhcp4.Inform:
out = s.inform(cfg, iface, p, cl)
default:
return nil
}
if out != nil {
ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName()}
if !dhcp4.IsZero(out.Packet.YIAddr) {
ev.IP = out.Packet.YIAddr.String()
}
if m := out.Packet.Get(dhcp4.OptMessage); m != nil {
ev.Detail = string(m)
}
s.events.add(ev)
}
return out
}
// ---- DISCOVER
func (s *Server) discover(ctx context.Context, cfg *config, iface string, subs []*subnet, p *dhcp4.Packet, cl client) *Outbound {
requested, _ := p.IPOption(dhcp4.OptRequestedIP)
for try := 0; try < 8; try++ {
ip, sub, fresh := s.choose(cfg, iface, subs, cl, requested)
if !ip.IsValid() {
s.warnOnce("full "+iface, 10*time.Minute, "no free address for a client: every pool on the interface is in use", "interface", iface, "client", cl.String())
s.events.add(Event{Time: s.now(), Interface: iface, Type: "NO ADDRESS", MAC: cl.mac, Detail: "every pool on " + iface + " is in use"})
return nil
}
// A fresh address from a pool is pinged first: something that
// answers is using it without a lease.
if fresh && cfg.settings.PingCheck && s.pinger != nil {
if s.pinger.Ping(ctx, ip, cfg.settings.EffectivePingTimeout()) {
s.conflict(ctx, cfg, iface, sub, ip)
continue
}
}
res := cfg.reservationFor(sub.ID, cl)
if res != nil && res.IP != ip {
res = nil
}
sid, err := s.serverAddr(iface, sub)
if err != nil {
s.warnOnce("sid "+iface, 10*time.Minute, "can't answer DHCP: "+err.Error(), "interface", iface)
return nil
}
r := p.Reply(dhcp4.Offer)
r.YIAddr = ip
s.addOptions(r, p, sub, res, sid, true)
return &Outbound{Packet: r}
}
return nil
}
// choose picks the address to offer: the client's reservation, the
// address it had, the one it asks for, or a free one from the pools. fresh
// says the address comes from a pool and the client never had it. The
// address is held for the client for a minute.
func (s *Server) choose(cfg *config, iface string, subs []*subnet, cl client, requested netip.Addr) (netip.Addr, *subnet, bool) {
now := s.now()
own := s.ownAddrs(iface)
s.mu.Lock()
defer s.mu.Unlock()
hold := func(ip netip.Addr, sub *subnet, fresh bool) (netip.Addr, *subnet, bool) {
s.offers[ip] = offer{cl: cl, expires: now.Add(offerHold)}
return ip, sub, fresh
}
// 1. A reservation.
for _, sub := range subs {
if r := cfg.reservationFor(sub.ID, cl); r != nil {
if s.usable(cfg, own, sub, r.IP, cl, now) {
return hold(r.IP, sub, false)
}
s.warnOnce("resbusy "+r.IP.String(), 10*time.Minute, "a client's reserved address is held by another client; handing out another one until it is free", "ip", r.IP, "client", cl.String())
}
}
// 2. The address the client had (an active lease first, then the most
// recent one).
var prev *model.Lease
for _, l := range s.leases {
if !l.Owns(cl.mac, cl.id) || l.State == model.LeaseDeclined {
continue
}
if prev == nil || l.ActiveAt(now) && !prev.ActiveAt(now) || l.ActiveAt(now) == prev.ActiveAt(now) && l.End.After(prev.End) {
prev = l
}
}
if prev != nil {
if sub := cfg.subnetFor(iface, prev.IP); sub != nil && s.usable(cfg, own, sub, prev.IP, cl, now) {
return hold(prev.IP, sub, false)
}
}
// 3. The address the client asks for.
if requested.IsValid() {
if sub := cfg.subnetFor(iface, requested); sub != nil && s.usable(cfg, own, sub, requested, cl, now) {
return hold(requested, sub, true)
}
}
// 4. A free address: one never handed out, else the one free the
// longest.
var oldest netip.Addr
var oldestSub *subnet
var oldestEnd time.Time
for _, sub := range subs {
for _, pool := range sub.Pools {
for v := model.U32(pool.Start); v <= model.U32(pool.End) && v != 0; v++ {
ip := model.FromU32(v)
if !s.usable(cfg, own, sub, ip, cl, now) {
continue
}
l := s.leases[ip]
if l == nil {
return hold(ip, sub, true)
}
if !oldest.IsValid() || l.End.Before(oldestEnd) {
oldest, oldestSub, oldestEnd = ip, sub, l.End
}
if v == model.U32(pool.End) {
break
}
}
}
}
if oldest.IsValid() {
return hold(oldest, oldestSub, true)
}
return netip.Addr{}, nil, false
}
// usable reports whether ip can go to the client (s.mu held): inside the
// subnet but not its network, broadcast or server address; reserved for
// this client or in a pool and not reserved for anyone else; not leased
// to, declined by or offered to someone else.
func (s *Server) usable(cfg *config, own map[netip.Addr]bool, sub *subnet, ip netip.Addr, cl client, now time.Time) bool {
if !sub.Prefix.Contains(ip) || ip == sub.network || ip == sub.broadcast || own[ip] {
return false
}
if r := cfg.resByIP[ip]; r != nil {
if !cl.matches(r) {
return false
}
} else {
inPool := false
for _, p := range sub.Pools {
if p.Contains(ip) {
inPool = true
break
}
}
if !inPool {
return false
}
// A client with a reservation in this subnet gets that address,
// not a pool address.
if r := cfg.reservationFor(sub.ID, cl); r != nil {
return false
}
}
if l := s.leases[ip]; l != nil && l.HeldAt(now) && (l.State == model.LeaseDeclined || !l.Owns(cl.mac, cl.id)) {
return false
}
if o, ok := s.offers[ip]; ok && now.Before(o.expires) && o.cl != cl {
return false
}
return true
}
// conflict holds back an address that answered a ping.
func (s *Server) conflict(ctx context.Context, cfg *config, iface string, sub *subnet, ip netip.Addr) {
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
delete(s.offers, ip)
l := &model.Lease{IP: ip, SubnetID: sub.ID, State: model.LeaseDeclined, Start: now, End: now.Add(cfg.settings.EffectiveDeclineHold()), Interface: iface, Hostname: "(answered a ping)"}
if err := s.putLease(ctx, l); err != nil {
s.log.Error("saving a conflict failed", "ip", ip, "err", err)
}
s.log.Warn("an address answered a ping before it was offered: something uses it without a lease; holding it back", "ip", ip, "interface", iface, "until", l.End.Format(time.RFC3339))
s.events.add(Event{Time: now, Interface: iface, Type: "CONFLICT", IP: ip.String(), Detail: "answered a ping; held back"})
}
// ---- REQUEST
func (s *Server) request(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client) *Outbound {
sidOpt, hasSID := p.IPOption(dhcp4.OptServerID)
reqIP, hasReq := p.IPOption(dhcp4.OptRequestedIP)
switch {
case hasSID:
// SELECTING: the client answers an offer, ours or another server's.
if !s.isOwnAddr(iface, sidOpt) {
s.mu.Lock()
for ip, o := range s.offers {
if o.cl == cl {
delete(s.offers, ip)
}
}
s.mu.Unlock()
return nil
}
if !hasReq {
return s.nak(cfg, iface, p, nil, "no requested address")
}
return s.ack(ctx, cfg, iface, p, cl, reqIP)
case hasReq:
// INIT-REBOOT: the client wants to keep the address it had.
return s.ack(ctx, cfg, iface, p, cl, reqIP)
case !dhcp4.IsZero(p.CIAddr):
// RENEWING (unicast to us) or REBINDING (broadcast).
return s.ack(ctx, cfg, iface, p, cl, p.CIAddr)
}
return s.nak(cfg, iface, p, nil, "no address in the request")
}
// ack gives the client ip if it may have it, saving the lease first;
// otherwise a NAK, so the client starts over.
func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client, ip netip.Addr) *Outbound {
sub := cfg.subnetFor(iface, ip)
if sub == nil {
return s.nak(cfg, iface, p, nil, fmt.Sprintf("%s does not belong on %s", ip, iface))
}
now := s.now()
own := s.ownAddrs(iface)
s.mu.Lock()
if !s.usable(cfg, own, sub, ip, cl, now) {
s.mu.Unlock()
why := "address not available"
if r := cfg.reservationFor(sub.ID, cl); r != nil && r.IP != ip {
why = "this client has a reservation for " + r.IP.String()
}
return s.nak(cfg, iface, p, sub, why)
}
start, host := now, p.HostName()
if l := s.leases[ip]; l != nil && l.Owns(cl.mac, cl.id) {
if l.ActiveAt(now) {
start = l.Start
}
// Renewals often leave the host name out.
if host == "" {
host = l.Hostname
}
}
l := &model.Lease{IP: ip, SubnetID: sub.ID, MAC: cl.mac, ClientID: cl.id, Hostname: host, State: model.LeaseActive,
Start: start, End: now.Add(sub.leaseTime), Interface: iface}
err := s.putLease(ctx, l)
delete(s.offers, ip)
s.mu.Unlock()
if err != nil {
// Without the lease saved, the address could go to someone else
// after a restart: don't hand it out.
s.log.Error("saving a lease failed; not answering", "ip", ip, "err", err)
return nil
}
sid, err := s.serverAddr(iface, sub)
if err != nil {
s.warnOnce("sid "+iface, 10*time.Minute, "can't answer DHCP: "+err.Error(), "interface", iface)
return nil
}
res := cfg.reservationFor(sub.ID, cl)
if res != nil && res.IP != ip {
res = nil
}
r := p.Reply(dhcp4.Ack)
r.YIAddr = ip
r.CIAddr = p.CIAddr
if !r.CIAddr.IsValid() {
r.CIAddr = netip.AddrFrom4([4]byte{})
}
s.addOptions(r, p, sub, res, sid, true)
return &Outbound{Packet: r, Dst: unicastTo(p)}
}
// nak refuses a request. Without authority (settings.not_authoritative),
// requests for addresses Steward doesn't know get no answer instead.
func (s *Server) nak(cfg *config, iface string, p *dhcp4.Packet, sub *subnet, why string) *Outbound {
if cfg.settings.NotAuthoritative && sub == nil {
return nil
}
sid, err := s.serverAddr(iface, sub)
if err != nil {
return nil
}
r := p.Reply(dhcp4.Nak)
r.Flags |= dhcp4.FlagBroadcast
r.Set(dhcp4.OptServerID, dhcp4.IP(sid))
r.Set(dhcp4.OptMessage, []byte(why))
return &Outbound{Packet: r}
}
// unicastTo is where a reply goes: the client's own address when it has
// one, else a broadcast.
func unicastTo(p *dhcp4.Packet) netip.Addr {
if !dhcp4.IsZero(p.CIAddr) {
return p.CIAddr
}
return netip.Addr{}
}
// ---- DECLINE, RELEASE, INFORM
func (s *Server) decline(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client) {
ip, ok := p.IPOption(dhcp4.OptRequestedIP)
if !ok {
return
}
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
l := s.leases[ip]
if l == nil || !l.Owns(cl.mac, cl.id) {
return
}
l2 := *l
l2.State, l2.End = model.LeaseDeclined, now.Add(cfg.settings.EffectiveDeclineHold())
if err := s.putLease(ctx, &l2); err != nil {
s.log.Error("saving a declined lease failed", "ip", ip, "err", err)
return
}
s.log.Warn("a client declined its address: another device uses it; holding it back", "ip", ip, "client", cl.String(), "interface", iface, "until", l2.End.Format(time.RFC3339))
}
func (s *Server) release(ctx context.Context, iface string, p *dhcp4.Packet, cl client) {
ip := p.CIAddr
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
l := s.leases[ip]
if l == nil || !l.Owns(cl.mac, cl.id) || !l.ActiveAt(now) {
return
}
l2 := *l
l2.State, l2.End = model.LeaseReleased, now
if err := s.putLease(ctx, &l2); err != nil {
s.log.Error("saving a released lease failed", "ip", ip, "err", err)
}
}
func (s *Server) inform(cfg *config, iface string, p *dhcp4.Packet, cl client) *Outbound {
if dhcp4.IsZero(p.CIAddr) {
return nil
}
sub := cfg.subnetFor(iface, p.CIAddr)
if sub == nil {
return nil
}
sid, err := s.serverAddr(iface, sub)
if err != nil {
return nil
}
var res *model.Reservation
if r := cfg.resByIP[p.CIAddr]; r != nil && cl.matches(r) {
res = r
}
r := p.Reply(dhcp4.Ack)
r.CIAddr = p.CIAddr
s.addOptions(r, p, sub, res, sid, false)
return &Outbound{Packet: r, Dst: p.CIAddr}
}
// ---- options
// Options always sent when configured, even when the client doesn't ask.
var essential = []byte{dhcp4.OptSubnetMask, dhcp4.OptRouters, dhcp4.OptDNSServers, dhcp4.OptDomainName, dhcp4.OptHostName}
// addOptions adds the server identifier, the lease times (lease true) and
// the configured options: the reservation's over the subnet's over the
// settings'. With a parameter request list only what the client asked
// for (and the essentials) is sent, in its order.
func (s *Server) addOptions(r, req *dhcp4.Packet, sub *subnet, res *model.Reservation, sid netip.Addr, lease bool) {
r.Set(dhcp4.OptServerID, dhcp4.IP(sid))
if lease {
lt := uint32(sub.leaseTime / time.Second)
r.Set(dhcp4.OptLeaseTime, dhcp4.U32(lt))
r.Set(dhcp4.OptRenewalTime, dhcp4.U32(lt/2))
r.Set(dhcp4.OptRebindingTime, dhcp4.U32(lt*7/8))
}
opts := sub.options
if res != nil && (len(res.Options) > 0 || res.Hostname != "") {
opts = encodeOptions(res.Options, sub.options)
if res.Hostname != "" {
opts[dhcp4.OptHostName] = []byte(res.Hostname)
}
}
all := map[byte][]byte{dhcp4.OptSubnetMask: dhcp4.IP(model.Mask(sub.Prefix))}
for k, v := range opts {
all[k] = v
}
var order []byte
added := map[byte]bool{}
addCode := func(c byte) {
if _, ok := all[c]; ok && !added[c] {
added[c] = true
order = append(order, c)
}
}
if prl := req.ParamRequest(); len(prl) > 0 {
for _, c := range prl {
addCode(c)
}
for _, c := range essential {
addCode(c)
}
} else {
for c := byte(1); c < 255; c++ {
addCode(c)
}
}
limit := req.MaxMessageSize() - 28 // IP and UDP headers
for _, c := range order {
r.Set(c, all[c])
if r.Len() > limit {
r.Options = r.Options[:len(r.Options)-1]
s.warnOnce(fmt.Sprintf("size %d", c), time.Hour, "a reply would be too large for the client; leaving out options", "first_left_out", dhcp4.ByCode(c).Name, "max_message_size", req.MaxMessageSize())
break
}
}
}
+446
View File
@@ -0,0 +1,446 @@
package dhcpd
import (
"context"
"errors"
"net"
"net/netip"
"strings"
"sync"
"testing"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
"steward/internal/store"
)
type fakeIfaces map[string][]netip.Prefix
func (f fakeIfaces) Addrs(name string) ([]netip.Prefix, error) {
a, ok := f[name]
if !ok {
return nil, errors.New("no interface " + name)
}
return a, nil
}
type fakePinger struct {
mu sync.Mutex
answers map[netip.Addr]bool
pinged []netip.Addr
}
func (f *fakePinger) Ping(_ context.Context, a netip.Addr, _ time.Duration) bool {
f.mu.Lock()
defer f.mu.Unlock()
f.pinged = append(f.pinged, a)
return f.answers[a]
}
type harness struct {
t *testing.T
s *Server
st *store.Store
ping *fakePinger
clock time.Time
}
func pool(s string) model.Pool {
p, err := model.ParsePool(s)
if err != nil {
panic(err)
}
return p
}
func newHarness(t *testing.T, edit func(st *model.State)) *harness {
t.Helper()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
ifs := fakeIfaces{
"eth0": {netip.MustParsePrefix("10.0.10.2/24")},
"eth0.20": {netip.MustParsePrefix("10.0.20.1/24")},
"eth1": {netip.MustParsePrefix("192.168.1.1/24")},
}
h := &harness{t: t, st: db, ping: &fakePinger{answers: map[netip.Addr]bool{}}, clock: time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)}
h.s = New(nil, db, ifs, h.ping)
h.s.now = func() time.Time { return h.clock }
st := &model.State{Settings: model.DefaultSettings(),
Subnets: []*model.Subnet{
{ID: 1, Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"), Pools: []model.Pool{pool("10.0.10.100-10.0.10.103")},
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}, {Code: 6, Value: "10.0.10.2"}, {Code: 15, Value: "lan"}, {Code: 42, Value: "10.0.10.2"}}},
{ID: 2, Name: "iot", Interface: "eth0.20", Prefix: netip.MustParsePrefix("10.0.20.0/24"), Pools: []model.Pool{pool("10.0.20.100-10.0.20.200")}, LeaseTime: 3600},
},
Reservations: []*model.Reservation{
{ID: 3, MAC: "aa:00:00:00:00:99", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas", Options: []model.OptionValue{{Code: 6, Value: "1.1.1.1"}}},
{ID: 4, MAC: "aa:00:00:00:00:98", IP: netip.MustParseAddr("10.0.10.101"), Hostname: "printer"},
}}
st.Settings.Options = []model.OptionValue{{Code: 6, Value: "9.9.9.9"}}
if edit != nil {
edit(st)
}
h.s.SetConfig(st)
return h
}
func mac(n byte) net.HardwareAddr { return net.HardwareAddr{0xaa, 0, 0, 0, 0, n} }
func (h *harness) send(iface string, p *dhcp4.Packet) *Outbound {
h.t.Helper()
// Through the wire format, as for real.
q, err := dhcp4.Parse(p.Marshal())
if err != nil {
h.t.Fatal(err)
}
out := h.s.Handle(context.Background(), iface, q)
if out != nil {
r, err := dhcp4.Parse(out.Packet.Marshal())
if err != nil {
h.t.Fatal(err)
}
out.Packet = r
}
return out
}
func (h *harness) discover(iface string, m net.HardwareAddr, opts ...dhcp4.Option) *Outbound {
p := dhcp4.NewRequest(dhcp4.Discover, m, 1)
for _, o := range opts {
p.Set(o.Code, o.Data)
}
return h.send(iface, p)
}
// lease runs DISCOVER and REQUEST and returns the ACK.
func (h *harness) lease(iface string, m net.HardwareAddr) *dhcp4.Packet {
h.t.Helper()
off := h.discover(iface, m)
if off == nil || off.Packet.Type() != dhcp4.Offer {
h.t.Fatalf("no offer for %s on %s", m, iface)
}
sid, _ := off.Packet.IPOption(dhcp4.OptServerID)
req := dhcp4.NewRequest(dhcp4.Request, m, 2)
req.Set(dhcp4.OptServerID, dhcp4.IP(sid))
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr))
req.Set(dhcp4.OptHostName, []byte("client"+m.String()[15:]))
ack := h.send(iface, req)
if ack == nil || ack.Packet.Type() != dhcp4.Ack {
h.t.Fatalf("no ACK for %s: %+v", m, ack)
}
if ack.Packet.YIAddr != off.Packet.YIAddr {
h.t.Fatalf("ACK %s differs from offer %s", ack.Packet.YIAddr, off.Packet.YIAddr)
}
return ack.Packet
}
func ipOpt(p *dhcp4.Packet, code byte) string {
return dhcp4.Decode(dhcp4.TypeIPs, p.Get(code))
}
func TestOfferAndAck(t *testing.T) {
h := newHarness(t, nil)
off := h.discover("eth0", mac(1))
if off == nil {
t.Fatal("no offer")
}
p := off.Packet
if p.YIAddr.String() != "10.0.10.100" || off.Dst.IsValid() {
t.Errorf("offered %s to %v", p.YIAddr, off.Dst)
}
if ipOpt(p, dhcp4.OptServerID) != "10.0.10.2" || ipOpt(p, dhcp4.OptSubnetMask) != "255.255.255.0" || ipOpt(p, dhcp4.OptRouters) != "10.0.10.1" {
t.Errorf("options: sid %s mask %s routers %s", ipOpt(p, 54), ipOpt(p, 1), ipOpt(p, 3))
}
// The subnet's DNS server wins over the settings'.
if ipOpt(p, dhcp4.OptDNSServers) != "10.0.10.2" || string(p.Get(dhcp4.OptDomainName)) != "lan" {
t.Errorf("dns %s domain %q", ipOpt(p, 6), p.Get(15))
}
if dhcp4.Decode(dhcp4.TypeU32, p.Get(dhcp4.OptLeaseTime)) != "43200" || dhcp4.Decode(dhcp4.TypeU32, p.Get(dhcp4.OptRenewalTime)) != "21600" {
t.Errorf("lease time %v", p.Get(51))
}
ack := h.lease("eth0", mac(1))
l := h.s.Lease(ack.YIAddr)
if l == nil || l.State != model.LeaseActive || l.MAC != mac(1).String() || l.Interface != "eth0" || l.SubnetID != 1 || l.Hostname != "client01" {
t.Fatalf("lease %+v", l)
}
// Saved before the ACK.
saved, _ := h.st.LoadLeases(context.Background())
if len(saved) != 1 || saved[0].IP != ack.YIAddr {
t.Errorf("saved %+v", saved)
}
}
func TestSubnetsPerInterface(t *testing.T) {
h := newHarness(t, nil)
if ip := h.lease("eth0.20", mac(1)).YIAddr; !strings.HasPrefix(ip.String(), "10.0.20.") {
t.Errorf("a client on eth0.20 got %s", ip)
}
// The same MAC on the office VLAN gets an office address.
if ip := h.lease("eth0", mac(1)).YIAddr; !strings.HasPrefix(ip.String(), "10.0.10.") {
t.Errorf("a client on eth0 got %s", ip)
}
// No subnet on eth1: no answer at all.
if out := h.discover("eth1", mac(2)); out != nil {
t.Errorf("answered on eth1: %+v", out)
}
// Asking eth0.20 for an office address: NAK.
req := dhcp4.NewRequest(dhcp4.Request, mac(3), 5)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.100")))
out := h.send("eth0.20", req)
if out == nil || out.Packet.Type() != dhcp4.Nak || !strings.Contains(string(out.Packet.Get(dhcp4.OptMessage)), "does not belong on eth0.20") {
t.Errorf("init-reboot on the wrong VLAN: %+v", out)
}
}
func TestReservations(t *testing.T) {
h := newHarness(t, nil)
ack := h.lease("eth0", mac(0x99))
if ack.YIAddr.String() != "10.0.10.20" || string(ack.Get(dhcp4.OptHostName)) != "nas" || ipOpt(ack, 6) != "1.1.1.1" {
t.Errorf("reserved client got %s host %q dns %s", ack.YIAddr, ack.Get(12), ipOpt(ack, 6))
}
// 10.0.10.101 is reserved for the printer: nobody else gets it, even
// though it is in the pool.
got := map[string]bool{}
for i := byte(1); i <= 3; i++ {
got[h.lease("eth0", mac(i)).YIAddr.String()] = true
}
if got["10.0.10.101"] || !got["10.0.10.100"] || !got["10.0.10.102"] || !got["10.0.10.103"] {
t.Errorf("pool addresses handed out: %v", got)
}
// The pool is now full (the reserved address doesn't count).
if out := h.discover("eth0", mac(4)); out != nil {
t.Errorf("offered %s from a full pool", out.Packet.YIAddr)
}
if h.lease("eth0", mac(0x98)).YIAddr.String() != "10.0.10.101" {
t.Error("the printer didn't get its reservation")
}
// A client with a reservation asking for another address gets a NAK
// that says why.
req := dhcp4.NewRequest(dhcp4.Request, mac(0x99), 7)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.102")))
out := h.send("eth0", req)
if out == nil || out.Packet.Type() != dhcp4.Nak || !strings.Contains(string(out.Packet.Get(dhcp4.OptMessage)), "reservation for 10.0.10.20") {
t.Errorf("got %+v", out)
}
}
func TestReturningClientKeepsAddress(t *testing.T) {
h := newHarness(t, nil)
first := h.lease("eth0", mac(1)).YIAddr
h.lease("eth0", mac(2))
rel := dhcp4.NewRequest(dhcp4.Release, mac(1), 9)
rel.CIAddr = first
if out := h.send("eth0", rel); out != nil {
t.Error("a release was answered")
}
if l := h.s.Lease(first); l.State != model.LeaseReleased {
t.Errorf("after release: %+v", l)
}
h.clock = h.clock.Add(time.Hour)
if again := h.lease("eth0", mac(1)).YIAddr; again != first {
t.Errorf("came back to %s, had %s", again, first)
}
// Expired leases too.
h.clock = h.clock.Add(48 * time.Hour)
if again := h.lease("eth0", mac(1)).YIAddr; again != first {
t.Errorf("after expiry got %s, had %s", again, first)
}
// A new client gets a never-used address before a released one.
if ip := h.lease("eth0", mac(3)).YIAddr; ip == first || ip.String() == "10.0.10.101" {
t.Errorf("new client got %s", ip)
}
}
func TestRenewAndRebind(t *testing.T) {
h := newHarness(t, nil)
ack := h.lease("eth0.20", mac(1))
start := h.s.Lease(ack.YIAddr).Start
h.clock = h.clock.Add(30 * time.Minute)
ren := dhcp4.NewRequest(dhcp4.Request, mac(1), 11)
ren.CIAddr = ack.YIAddr
out := h.send("eth0.20", ren)
if out == nil || out.Packet.Type() != dhcp4.Ack || out.Dst != ack.YIAddr || out.Packet.CIAddr != ack.YIAddr {
t.Fatalf("renewal: %+v", out)
}
l := h.s.Lease(ack.YIAddr)
if l.Hostname != "client01" {
t.Errorf("a renewal without a host name lost it: %q", l.Hostname)
}
if !l.Start.Equal(start) || !l.End.Equal(h.clock.Add(time.Hour)) {
t.Errorf("renewed lease %v - %v", l.Start, l.End)
}
// Another client renewing that address: NAK.
ren2 := dhcp4.NewRequest(dhcp4.Request, mac(2), 12)
ren2.CIAddr = ack.YIAddr
if out := h.send("eth0.20", ren2); out == nil || out.Packet.Type() != dhcp4.Nak {
t.Errorf("someone else's renewal: %+v", out)
}
}
func TestInitReboot(t *testing.T) {
h := newHarness(t, nil)
ip := h.lease("eth0", mac(1)).YIAddr
req := dhcp4.NewRequest(dhcp4.Request, mac(1), 20)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(ip))
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Ack || out.Dst.IsValid() {
t.Errorf("own address after reboot: %+v", out)
}
// Someone else's address: NAK.
req = dhcp4.NewRequest(dhcp4.Request, mac(2), 21)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(ip))
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Nak {
t.Errorf("another's address: %+v", out)
}
// A free pool address from a client Steward hasn't seen (say, after
// switching from another server): ACK.
req = dhcp4.NewRequest(dhcp4.Request, mac(3), 22)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.103")))
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Ack {
t.Errorf("free address: %+v", out)
}
// An address outside the pools: NAK.
req = dhcp4.NewRequest(dhcp4.Request, mac(4), 23)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.50")))
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Nak {
t.Errorf("address outside the pools: %+v", out)
}
}
func TestOtherServerChosen(t *testing.T) {
h := newHarness(t, nil)
off := h.discover("eth0", mac(1))
req := dhcp4.NewRequest(dhcp4.Request, mac(1), 2)
req.Set(dhcp4.OptServerID, dhcp4.IP(netip.MustParseAddr("10.0.10.254")))
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.77")))
if out := h.send("eth0", req); out != nil {
t.Errorf("answered a request to another server: %+v", out)
}
// The offer was given up: another client can have the address.
if out := h.discover("eth0", mac(2)); out == nil || out.Packet.YIAddr != off.Packet.YIAddr {
t.Errorf("offer not released: %+v", out)
}
}
func TestDeclineAndPingConflict(t *testing.T) {
h := newHarness(t, nil)
ip := h.lease("eth0", mac(1)).YIAddr
dec := dhcp4.NewRequest(dhcp4.Decline, mac(1), 30)
dec.Set(dhcp4.OptRequestedIP, dhcp4.IP(ip))
h.send("eth0", dec)
if l := h.s.Lease(ip); l.State != model.LeaseDeclined || !l.End.Equal(h.clock.Add(24*time.Hour)) {
t.Errorf("declined lease %+v", l)
}
// Not offered again, not even to the client that declined it.
if out := h.discover("eth0", mac(1)); out == nil || out.Packet.YIAddr == ip {
t.Errorf("declined address offered again: %+v", out)
}
// An address that answers a ping is skipped and held back.
h2 := newHarness(t, nil)
h2.ping.answers[netip.MustParseAddr("10.0.10.100")] = true
off := h2.discover("eth0", mac(1))
if off == nil || off.Packet.YIAddr.String() != "10.0.10.102" {
t.Fatalf("offer after a conflict: %+v", off)
}
if l := h2.s.Lease(netip.MustParseAddr("10.0.10.100")); l == nil || l.State != model.LeaseDeclined {
t.Errorf("conflict not recorded: %+v", l)
}
// Reservations aren't pinged (the device may still have its address).
h2.ping.pinged = nil
h2.lease("eth0", mac(0x99))
for _, a := range h2.ping.pinged {
if a.String() == "10.0.10.20" {
t.Error("pinged a reserved address")
}
}
}
func TestSharedNetwork(t *testing.T) {
h := newHarness(t, func(st *model.State) {
st.Subnets = append(st.Subnets, &model.Subnet{ID: 5, Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.11.0/24"), Pools: []model.Pool{pool("10.0.11.10-10.0.11.11")}})
})
got := map[string]bool{}
for i := byte(1); i <= 5; i++ {
got[h.lease("eth0", mac(i)).YIAddr.String()] = true
}
if !got["10.0.11.10"] || !got["10.0.11.11"] {
t.Errorf("second subnet not used: %v", got)
}
// Its clients are answered from the interface's address (10.0.10.2),
// with the second subnet's mask.
l := h.s.Lease(netip.MustParseAddr("10.0.11.10"))
ren := dhcp4.NewRequest(dhcp4.Request, net.HardwareAddr(mustMAC(l.MAC)), 3)
ren.CIAddr = l.IP
out := h.send("eth0", ren)
if out == nil || ipOpt(out.Packet, 54) != "10.0.10.2" || ipOpt(out.Packet, 1) != "255.255.255.0" {
t.Errorf("renewal in the second subnet: %+v", out)
}
}
func mustMAC(s string) net.HardwareAddr {
m, err := net.ParseMAC(s)
if err != nil {
panic(err)
}
return m
}
func TestParameterRequestList(t *testing.T) {
h := newHarness(t, nil)
off := h.discover("eth0", mac(1), dhcp4.Option{Code: dhcp4.OptParamRequest, Data: []byte{42, 1}})
var codes []byte
for _, o := range off.Packet.Options {
codes = append(codes, o.Code)
}
// Type, server ID and lease times; then NTP and mask as asked; then
// the essentials the client didn't ask for.
want := []byte{53, 54, 51, 58, 59, 42, 1, 3, 6, 15}
if string(codes) != string(want) {
t.Errorf("options %v, want %v", codes, want)
}
}
func TestInformAndRelay(t *testing.T) {
h := newHarness(t, nil)
inf := dhcp4.NewRequest(dhcp4.Inform, mac(1), 40)
inf.CIAddr = netip.MustParseAddr("10.0.10.50")
out := h.send("eth0", inf)
if out == nil || out.Packet.Type() != dhcp4.Ack || out.Dst != inf.CIAddr || out.Packet.Has(dhcp4.OptLeaseTime) || ipOpt(out.Packet, 3) != "10.0.10.1" {
t.Errorf("inform: %+v", out)
}
rel := dhcp4.NewRequest(dhcp4.Discover, mac(2), 41)
rel.GIAddr = netip.MustParseAddr("10.0.30.1")
if out := h.send("eth0", rel); out != nil {
t.Error("answered a relayed request")
}
}
func TestNotAuthoritative(t *testing.T) {
h := newHarness(t, func(st *model.State) { st.Settings.NotAuthoritative = true })
req := dhcp4.NewRequest(dhcp4.Request, mac(3), 5)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("192.168.7.7")))
if out := h.send("eth0", req); out != nil {
t.Errorf("NAK without authority: %+v", out)
}
}
func TestDisabledSubnet(t *testing.T) {
h := newHarness(t, func(st *model.State) { st.Subnets[1].Disabled = true })
if out := h.discover("eth0.20", mac(1)); out != nil {
t.Error("a disabled subnet answered")
}
}
func TestPrune(t *testing.T) {
h := newHarness(t, nil)
ip := h.lease("eth0", mac(1)).YIAddr
h.clock = h.clock.Add(40 * 24 * time.Hour)
h.s.Prune(context.Background())
if h.s.Lease(ip) != nil {
t.Error("old lease kept")
}
if ls, _ := h.st.LoadLeases(context.Background()); len(ls) != 0 {
t.Errorf("old lease still saved: %+v", ls)
}
}
+211
View File
@@ -0,0 +1,211 @@
package dhcpd
import (
"context"
"errors"
"net"
"net/netip"
"sort"
"strings"
"sync"
"time"
"steward/internal/dhcp4"
)
// packetConn is what a listener reads from and writes to.
type packetConn interface {
ReadFromUDPAddrPort(b []byte) (int, netip.AddrPort, error)
WriteToUDPAddrPort(b []byte, addr netip.AddrPort) (int, error)
Close() error
}
type listener struct {
conn packetConn
err string
since time.Time
}
type listeners struct {
mu sync.Mutex
open map[string]*listener
// failed are interfaces whose socket couldn't be opened, with why.
failed map[string]*listener
}
// listenFunc opens the socket for an interface (listen_linux.go); tests
// replace it.
var listenFunc = listenInterface
// Run serves DHCP until ctx ends: it opens a socket on every interface
// that has a subnet, and follows configuration changes. Interfaces that
// don't exist yet (a VLAN that comes up later) are retried every 30
// seconds.
func (s *Server) Run(ctx context.Context) {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
prune := time.NewTicker(time.Hour)
defer prune.Stop()
for {
s.reconcile(ctx)
select {
case <-ctx.Done():
s.closeAll()
return
case <-s.changed:
case <-t.C:
case <-prune.C:
s.Prune(ctx)
}
}
}
func (s *Server) reconcile(ctx context.Context) {
want := map[string]bool{}
for iface := range s.cfg.Load().byIface {
want[iface] = true
}
s.listen.mu.Lock()
defer s.listen.mu.Unlock()
if s.listen.open == nil {
s.listen.open, s.listen.failed = map[string]*listener{}, map[string]*listener{}
}
for iface, l := range s.listen.open {
if !want[iface] {
l.conn.Close()
delete(s.listen.open, iface)
s.log.Info("stopped serving DHCP on an interface (no subnets left)", "interface", iface)
}
}
for iface := range s.listen.failed {
if !want[iface] {
delete(s.listen.failed, iface)
}
}
for iface := range want {
if s.listen.open[iface] != nil {
continue
}
conn, err := listenFunc(iface, s.ServerPort)
if err != nil {
if f := s.listen.failed[iface]; f == nil || f.err != err.Error() {
s.log.Warn("can't serve DHCP on an interface; retrying every 30 seconds", "interface", iface, "err", err)
s.listen.failed[iface] = &listener{err: err.Error(), since: s.now()}
}
continue
}
delete(s.listen.failed, iface)
l := &listener{conn: conn, since: s.now()}
s.listen.open[iface] = l
s.log.Info("serving DHCP", "interface", iface, "port", s.ServerPort)
go s.serve(ctx, iface, conn)
}
}
func (s *Server) closeAll() {
s.listen.mu.Lock()
defer s.listen.mu.Unlock()
for iface, l := range s.listen.open {
l.conn.Close()
delete(s.listen.open, iface)
}
}
// serve reads requests from one interface's socket. Each is answered in
// its own goroutine (a ping check takes up to its timeout), at most 32 at
// a time.
func (s *Server) serve(ctx context.Context, iface string, conn packetConn) {
sem := make(chan struct{}, 32)
buf := make([]byte, 4096)
for {
n, src, err := conn.ReadFromUDPAddrPort(buf)
if err != nil {
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
return
}
s.warnOnce("read "+iface, time.Minute, "reading a DHCP request failed", "interface", iface, "err", err)
time.Sleep(100 * time.Millisecond)
continue
}
p, err := dhcp4.Parse(append([]byte(nil), buf[:n]...))
if err != nil {
s.warnOnce("parse "+iface+" "+src.Addr().String(), 10*time.Minute, "ignoring a malformed DHCP message", "interface", iface, "from", src, "err", err)
continue
}
sem <- struct{}{}
go func() {
defer func() {
<-sem
if v := recover(); v != nil {
s.log.Error("panic answering a DHCP request", "interface", iface, "panic", v)
}
}()
if out := s.Handle(ctx, iface, p); out != nil {
s.send(conn, iface, out)
}
}()
}
}
func (s *Server) send(conn packetConn, iface string, out *Outbound) {
dst := netip.AddrPortFrom(broadcast, uint16(s.ClientPort))
if out.Dst.IsValid() {
dst = netip.AddrPortFrom(out.Dst, uint16(s.ClientPort))
}
if _, err := conn.WriteToUDPAddrPort(out.Packet.Marshal(), dst); err != nil {
s.warnOnce("send "+iface, time.Minute, "sending a DHCP reply failed", "interface", iface, "to", dst, "err", err)
}
}
// InterfaceStatus says whether DHCP is served on an interface.
type InterfaceStatus struct {
Name string `json:"name"`
Listening bool `json:"listening"`
Error string `json:"error,omitempty"`
Since time.Time `json:"since"`
Addresses []string `json:"addresses"`
Subnets []int64 `json:"subnets"`
}
// Status lists the interfaces that have subnets.
func (s *Server) Status() []InterfaceStatus {
cfg := s.cfg.Load()
s.listen.mu.Lock()
defer s.listen.mu.Unlock()
var out []InterfaceStatus
for iface, subs := range cfg.byIface {
st := InterfaceStatus{Name: iface, Addresses: []string{}, Subnets: []int64{}}
for _, sub := range subs {
st.Subnets = append(st.Subnets, sub.ID)
}
if l := s.listen.open[iface]; l != nil {
st.Listening, st.Since = true, l.since
} else if l := s.listen.failed[iface]; l != nil {
st.Error, st.Since = l.err, l.since
} else {
st.Error = "starting"
}
if addrs, err := s.ifaces.Addrs(iface); err == nil {
for _, a := range addrs {
st.Addresses = append(st.Addresses, a.String())
}
}
out = append(out, st)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// explainListenError adds what to do to a socket error.
func explainListenError(iface string, port int, err error) error {
msg := err.Error()
switch {
case strings.Contains(msg, "no such device"), strings.Contains(msg, "no such network interface"):
return errors.New("there is no interface " + iface + " on this server (yet)")
case strings.Contains(msg, "permission denied") && port < 1024:
return errors.New(msg + " (port 67 needs the CAP_NET_BIND_SERVICE capability; steward.service grants it, so check the installed unit: systemctl cat steward)")
case strings.Contains(msg, "address already in use"):
return errors.New(msg + " (another DHCP server, such as Kea or dnsmasq, is listening on this interface; stop it first)")
}
return err
}
+40
View File
@@ -0,0 +1,40 @@
package dhcpd
import (
"context"
"net"
"strconv"
"syscall"
)
// listenInterface opens a UDP socket on the DHCP server port that only
// sees the interface's traffic (SO_BINDTODEVICE), so a request's
// interface is known, and that may send broadcasts out of it. Sockets for
// several interfaces share the port (SO_REUSEADDR with different
// devices).
func listenInterface(iface string, port int) (packetConn, error) {
if _, err := net.InterfaceByName(iface); err != nil {
return nil, explainListenError(iface, port, err)
}
var serr error
lc := net.ListenConfig{Control: func(network, address string, c syscall.RawConn) error {
err := c.Control(func(fd uintptr) {
if serr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); serr != nil {
return
}
if serr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1); serr != nil {
return
}
serr = syscall.SetsockoptString(int(fd), syscall.SOL_SOCKET, syscall.SO_BINDTODEVICE, iface)
})
if err != nil {
return err
}
return serr
}}
pc, err := lc.ListenPacket(context.Background(), "udp4", "0.0.0.0:"+strconv.Itoa(port))
if err != nil {
return nil, explainListenError(iface, port, err)
}
return pc.(*net.UDPConn), nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package dhcpd
import "errors"
func listenInterface(iface string, port int) (packetConn, error) {
return nil, errors.New("Steward's DHCP server runs on Linux only")
}
+84
View File
@@ -0,0 +1,84 @@
package dhcpd
import (
"context"
"log/slog"
"net"
"net/netip"
"os"
"sync"
"sync/atomic"
"time"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
)
// ICMPPinger pings with an unprivileged ICMP socket (Linux "ping sockets",
// allowed for the groups in net.ipv4.ping_group_range, which systemd opens
// to everyone). When the socket can't be opened, the check is skipped and
// Unavailable says why.
type ICMPPinger struct {
Log *slog.Logger
seq atomic.Uint32
once sync.Once
// Unavailable is why pings can't be sent ("" when they can).
Unavailable atomic.Value
}
// Ping reports whether a answers an echo request within timeout.
func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Duration) bool {
conn, err := icmp.ListenPacket("udp4", "0.0.0.0")
if err != nil {
p.once.Do(func() {
msg := "can't send pings, so addresses aren't checked before they are offered: " + err.Error() + " (check net.ipv4.ping_group_range)"
p.Unavailable.Store(msg)
if p.Log != nil {
p.Log.Warn(msg)
}
})
return false
}
defer conn.Close()
seq := int(p.seq.Add(1) & 0xffff)
msg := icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: os.Getpid() & 0xffff, Seq: seq, Data: []byte("steward conflict check")}}
b, err := msg.Marshal(nil)
if err != nil {
return false
}
dst := &net.UDPAddr{IP: a.AsSlice()}
if _, err := conn.WriteTo(b, dst); err != nil {
return false
}
deadline := time.Now().Add(timeout)
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
}
conn.SetReadDeadline(deadline)
buf := make([]byte, 1500)
for {
n, from, err := conn.ReadFrom(buf)
if err != nil {
return false
}
ua, ok := from.(*net.UDPAddr)
if !ok || !ua.IP.Equal(dst.IP) {
continue
}
m, err := icmp.ParseMessage(1, buf[:n])
if err != nil {
continue
}
if e, ok := m.Body.(*icmp.Echo); ok && m.Type == ipv4.ICMPTypeEchoReply && e.Seq == seq {
return true
}
}
}
// Problem says why pings can't be sent ("" when they can).
func (p *ICMPPinger) Problem() string {
if v, ok := p.Unavailable.Load().(string); ok {
return v
}
return ""
}
+371
View File
@@ -0,0 +1,371 @@
// 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
}