Files
steward/internal/dhcpd/handle.go
T
abrown38andClaude Opus 5.5 4fd72998ec Reservation lease time; reservations by MAC address or client ID
- A reservation can have its own lease time (--lease-time, the
  reservation dialog), over the subnet's. Schema migration 6.
- A reservation with a MAC address and a client ID matches either one
  (a dual-boot machine sends two client IDs from one MAC); before, it
  was found by MAC and then refused. A lease of the reserved address by
  the client's other identity counts as its own.
- The Leases page shows a client's client ID; reserving a lease uses
  both, or one with --by / the dialog's choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:26:24 +01:00

486 lines
15 KiB
Go

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, in the subnet's order
// (order.go), else the one free the longest.
for _, sub := range subs {
if ip, ok := s.neverUsed(cfg, own, sub, cl, now); ok {
return hold(ip, sub, true)
}
}
var oldest netip.Addr
var oldestSub *subnet
var oldestEnd time.Time
for _, sub := range subs {
for _, pool := range sub.Pools {
eachIn(pool, false, func(ip netip.Addr) bool {
if l := s.leases[ip]; l != nil && s.usable(cfg, own, sub, ip, cl, now) && (!oldest.IsValid() || l.End.Before(oldestEnd)) {
oldest, oldestSub, oldestEnd = ip, sub, l.End
}
return false
})
}
}
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
}
res := cfg.resByIP[ip]
if res != nil {
if !cl.matches(res) {
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
}
}
// A lease of the reserved address by the reservation's client under
// its other identity (the other OS of a dual-boot machine) is its own.
if l := s.leases[ip]; l != nil && l.HeldAt(now) && (l.State == model.LeaseDeclined ||
!l.Owns(cl.mac, cl.id) && !(res != nil && (client{mac: l.MAC, id: l.ClientID}).matches(res))) {
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()
var seen *time.Time
if l := s.leases[ip]; l != nil && l.Owns(cl.mac, cl.id) {
seen = l.LastSeen
if l.ActiveAt(now) {
start = l.Start
}
// Renewals often leave the host name out.
if host == "" {
host = l.Hostname
}
}
res := cfg.reservationFor(sub.ID, cl)
if res != nil && res.IP != ip {
res = nil
}
l := &model.Lease{IP: ip, SubnetID: sub.ID, MAC: cl.mac, ClientID: cl.id, Hostname: host, State: model.LeaseActive,
Start: start, End: now.Add(leaseTime(sub, res)), Interface: iface, LastSeen: seen}
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
}
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}
}
// leaseTime is a lease's length: the reservation's, else the subnet's.
func leaseTime(sub *subnet, res *model.Reservation) time.Duration {
if res != nil && res.LeaseTime > 0 {
return time.Duration(res.LeaseTime) * time.Second
}
return sub.leaseTime
}
// ---- 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(leaseTime(sub, res) / 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
}
}
}