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:
commit
3c0808591f
111 files changed
+17851
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user