Allow and deny lists per subnet; every option of a DHCP message in words

- A subnet is a deny-list subnet (the default: everyone not denied) or
  an allow-list subnet (only clients allowed or with a reservation);
  the deny list counts in both. Entries are MAC addresses, prefixes
  (aa:bb:cc:*) or client IDs. A refused client gets no answer (DENIED
  in the activity log, with an Allow button) or a NAK on renewal.
  steward access, the subnet's Access tab. Schema migration 7.
- The activity log keeps each message's header fields and options,
  decoded into words (dhcp4.Describe): the parameter request list by
  name, client IDs, lease times. ▸ on an Activity row, steward
  activity show N / --details, GET /events/{id}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-18 20:27:51 +01:00
1 parent b3c8329ae8
commit 3ad2f981d4
30 files changed
+1092 -37

No files matched your search

+39 -2
View File
@@ -41,7 +41,8 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out
}
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()}
ev := Event{Time: s.now(), Interface: iface, Type: t.String(), MAC: cl.mac, Hostname: p.HostName(), Direction: "in", ClientID: cl.id}
ev.Fields, ev.Options = p.Describe()
if a, ok := p.IPOption(dhcp4.OptRequestedIP); ok {
ev.IP = a.String()
} else if !dhcp4.IsZero(p.CIAddr) {
@@ -64,7 +65,13 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out
return nil
}
if out != nil {
ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName()}
ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName(), Direction: "out", ClientID: cl.id}
ev.Fields, ev.Options = out.Packet.Describe()
if out.Dst.IsValid() {
ev.Fields = append(ev.Fields, dhcp4.Field{Name: "sent to", Value: out.Dst.String() + " (unicast)"})
} else {
ev.Fields = append(ev.Fields, dhcp4.Field{Name: "sent to", Value: "255.255.255.255 (broadcast on " + iface + ")"})
}
if !dhcp4.IsZero(out.Packet.YIAddr) {
ev.IP = out.Packet.YIAddr.String()
}
@@ -79,6 +86,22 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out
// ---- DISCOVER
func (s *Server) discover(ctx context.Context, cfg *config, iface string, subs []*subnet, p *dhcp4.Packet, cl client) *Outbound {
// Only the subnets whose access lists let the client in.
var allowed []*subnet
var refusedBy *subnet
why := ""
for _, sub := range subs {
if ok, reason := cfg.admits(sub, cl); ok {
allowed = append(allowed, sub)
} else {
refusedBy, why = sub, reason
}
}
if len(allowed) == 0 {
s.denied(iface, refusedBy, cl, why)
return nil
}
subs = allowed
requested, _ := p.IPOption(dhcp4.OptRequestedIP)
for try := 0; try < 8; try++ {
ip, sub, fresh := s.choose(cfg, iface, subs, cl, requested)
@@ -238,6 +261,11 @@ func (s *Server) conflict(ctx context.Context, cfg *config, iface string, sub *s
s.events.add(Event{Time: now, Interface: iface, Type: "CONFLICT", IP: ip.String(), Detail: "answered a ping; held back"})
}
// denied records a client a subnet's access lists refused.
func (s *Server) denied(iface string, sub *subnet, cl client, why string) {
s.events.add(Event{Time: s.now(), Interface: iface, Type: "DENIED", MAC: cl.mac, ClientID: cl.id, SubnetID: sub.ID, Detail: "no answer: the client is " + why})
}
// ---- REQUEST
func (s *Server) request(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client) *Outbound {
@@ -277,6 +305,11 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa
if sub == nil {
return s.nak(cfg, iface, p, nil, fmt.Sprintf("%s does not belong on %s", ip, iface))
}
// A client refused since it got its lease: a NAK makes it start over
// at once (its DISCOVER then gets no answer).
if ok, why := cfg.admits(sub, cl); !ok {
return s.nak(cfg, iface, p, sub, "this client is "+why)
}
now := s.now()
own := s.ownAddrs(iface)
s.mu.Lock()
@@ -403,6 +436,10 @@ func (s *Server) inform(cfg *config, iface string, p *dhcp4.Packet, cl client) *
if sub == nil {
return nil
}
if ok, why := cfg.admits(sub, cl); !ok {
s.denied(iface, sub, cl, why)
return nil
}
sid, err := s.serverAddr(iface, sub)
if err != nil {
return nil