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

+48
View File
@@ -612,3 +612,51 @@ func TestReservationLeaseTimeAndClientID(t *testing.T) {
t.Errorf("stranger: %s %d", ack.YIAddr, u32(ack))
}
}
func TestAccessLists(t *testing.T) {
// A deny-list subnet: everyone but the denied.
h := newHarness(t, func(st *model.State) {
st.Subnets[0].Deny = []model.ClientMatch{{Match: "aa:00:00:00:00:02"}, {Match: "aa:00:00:00:00:9*"}}
})
if h.discover("eth0", mac(1)) == nil {
t.Error("an unlisted client was refused")
}
for _, m := range []byte{2, 0x95} {
if out := h.discover("eth0", mac(m)); out != nil {
t.Errorf("denied %s got %s", mac(m), out.Packet.YIAddr)
}
}
if evs := h.s.Events(1, nil); evs[0].Type != "DENIED" || !strings.Contains(evs[0].Detail, "deny list of office") {
t.Errorf("event %+v", evs[0])
}
// The deny list wins over a reservation (the printer, aa:...:98).
if out := h.discover("eth0", mac(0x98)); out != nil {
t.Errorf("a denied reserved client got %s", out.Packet.YIAddr)
}
// An allow-list subnet: the allowed, by MAC, prefix or client ID, and
// clients with a reservation.
h = newHarness(t, func(st *model.State) {
st.Subnets[0].Access = model.AccessAllowList
st.Subnets[0].Allow = []model.ClientMatch{{Match: "aa:00:00:00:00:01"}, {Match: "id ff07"}}
})
ip := h.lease("eth0", mac(1)).YIAddr
if h.discover("eth0", mac(3)) != nil {
t.Error("an unlisted client got an address")
}
if h.discover("eth0", mac(7), dhcp4.Option{Code: dhcp4.OptClientID, Data: []byte{0xff, 7}}) == nil {
t.Error("a client allowed by client ID was refused")
}
if h.discover("eth0", mac(0x99)) == nil {
t.Error("a reserved client was refused")
}
// Taken off the list: its renewal gets a NAK.
st := h.s.cfg.Load().state
st.Subnets[0].Allow = st.Subnets[0].Allow[1:]
h.s.SetConfig(st)
ren := dhcp4.NewRequest(dhcp4.Request, mac(1), 9)
ren.CIAddr = ip
if out := h.send("eth0", ren); out == nil || out.Packet.Type() != dhcp4.Nak || !strings.Contains(string(out.Packet.Get(dhcp4.OptMessage)), "allow list") {
t.Errorf("renewal of a refused client: %+v", out)
}
}