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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user