- 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>
486 lines
15 KiB
Go
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
|
|
}
|
|
}
|
|
}
|