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

+51
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
@@ -247,6 +248,28 @@ func TestLeasesThroughDHCP(t *testing.T) {
if len(evs) != 4 || evs[0].Type != "DHCPACK" {
t.Errorf("events %+v", evs)
}
// Details: on their own, or with ?details=1.
if len(evs[0].Options) != 0 {
t.Errorf("list with options: %+v", evs[0])
}
var ev api.Event
if code := e.call(e.token, "GET", "/events/"+fmt.Sprint(evs[0].ID), nil, &ev); code != 200 || ev.Direction != "out" || len(ev.Options) == 0 {
t.Errorf("event: %d %+v", code, ev)
}
found := false
for _, o := range ev.Options {
found = found || o.Name == "lease-time" && o.Value == "43200 s (12h)"
}
if !found {
t.Errorf("no lease time in %+v", ev.Options)
}
e.call(e.token, "GET", "/events?details=1&mac="+mac.String(), nil, &evs)
if len(evs[3].Options) == 0 || evs[3].Direction != "in" {
t.Errorf("details=1: %+v", evs[3])
}
if code := e.call(e.token, "GET", "/events/99999", nil, nil); code != 404 {
t.Errorf("missing event: %d", code)
}
// By subnet: its addresses, and DISCOVERs on its interface.
e.call(e.token, "POST", "/subnets", api.SubnetInput{Name: ptr("office"), Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0")}, nil)
for ref, want := range map[string]int{"iot": 4, "10.0.20.0/24": 4, "office": 0} {
@@ -476,3 +499,31 @@ func TestReserveByClientID(t *testing.T) {
t.Errorf("lease time: %d", code)
}
}
func TestAccessAPI(t *testing.T) {
e := newEnv(t)
var mr api.MutationResponse
if code := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), Access: ptr("allow-list"), Pools: &[]string{"10.0.20.100-10.0.20.110"}}, &mr); code != 201 || len(mr.Warnings) == 0 || mr.Warnings[0].Code != "allow_list_empty" {
t.Fatalf("create: %d %+v", code, mr.Warnings)
}
in := api.SubnetInput{Allow: &[]model.ClientMatch{{Match: "AA-BB-CC-*", Comment: " sonos "}, {Match: "id 01:AA:BB"}}, Deny: &[]model.ClientMatch{{Match: "aabbcc000001"}}}
if code := e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", in, nil); code != 200 {
t.Fatalf("patch: %d", code)
}
sn := e.eng.State().Subnets[0]
if sn.Access != "allow-list" || sn.Allow[0] != (model.ClientMatch{Match: "aa:bb:cc:*", Comment: "sonos"}) || sn.Allow[1].Match != "id 01aabb" || sn.Deny[0].Match != "aa:bb:cc:00:00:01" {
t.Errorf("subnet %+v %+v %+v", sn.Access, sn.Allow, sn.Deny)
}
if code := e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", api.SubnetInput{Deny: &[]model.ClientMatch{{Match: "nonsense"}}}, nil); code != 400 {
t.Errorf("bad entry: %d", code)
}
var eb api.ErrorBody
if code := e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", api.SubnetInput{Access: ptr("whitelist")}, &eb); code != 422 || eb.Error.Problems[0].Code != "access_mode" {
t.Errorf("bad mode: %d %+v", code, eb)
}
// "deny-list" is the default, stored as "".
e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", api.SubnetInput{Access: ptr("deny-list")}, nil)
if sn = e.eng.State().Subnets[0]; sn.Access != "" || len(sn.Allow) != 2 {
t.Errorf("deny-list keeps the allow list: %+v", sn)
}
}