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 } 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() 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 } } 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, 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 } 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 } } }