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

+20
View File
@@ -238,6 +238,26 @@ func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
if in.AddressOrder != nil {
sn.AddressOrder = orderInput(*in.AddressOrder)
}
if in.Access != nil {
sn.Access = strings.ToLower(strings.TrimSpace(*in.Access))
}
for _, l := range []struct {
in *[]model.ClientMatch
out *[]model.ClientMatch
}{{in.Allow, &sn.Allow}, {in.Deny, &sn.Deny}} {
if l.in == nil {
continue
}
list := []model.ClientMatch{}
for _, m := range *l.in {
norm, err := model.ParseClientMatch(m.Match)
if err != nil {
return badRequest("%v", err)
}
list = append(list, model.ClientMatch{Match: norm, Comment: strings.TrimSpace(m.Comment)})
}
*l.out = list
}
if in.LeaseTime != nil {
sn.LeaseTime = *in.LeaseTime
}
+22
View File
@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
@@ -201,14 +202,35 @@ func (s *Server) listEvents(w http.ResponseWriter, r *http.Request, p *auth.Prin
}
return true
})
details := q.Get("details") == "1"
out := make([]api.Event, len(evs))
for i, e := range evs {
if !details {
e.Fields, e.Options = nil, nil
}
out[i] = api.Event(e)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// getEvent: GET /events/{id}, one message with its fields and options.
func (s *Server) getEvent(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLeases, false); err != nil {
return err
}
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
return badRequest("%q is not an event number", r.PathValue("id"))
}
e, ok := s.dhcp.Event(id)
if !ok {
return notFound("no event %d: only the last 1000 are kept, and none from before stewardd started", id)
}
writeJSON(w, http.StatusOK, api.Event(e))
return nil
}
// ---- status and audit
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
+1
View File
@@ -60,6 +60,7 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/leases/{ip}", s.authed(s.deleteLease))
m.HandleFunc("POST "+p+"/leases/{ip}/reserve", s.authed(s.reserveLease))
m.HandleFunc("GET "+p+"/events", s.authed(s.listEvents))
m.HandleFunc("GET "+p+"/events/{id}", s.authed(s.getEvent))
// Status and audit
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
+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)
}
}