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

+174
View File
@@ -0,0 +1,174 @@
package main
import (
"fmt"
"io"
"github.com/spf13/cobra"
"steward/internal/api"
"steward/internal/model"
)
func accessCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "access",
Short: "Who a subnet answers: its allow and deny lists",
Long: `A subnet answers every client except those on its deny list (a
deny-list subnet, the default), or, as an allow-list subnet, only clients
on its allow list or with a reservation in it. The deny list counts in
both. A refused client gets no answer (DENIED in 'steward activity'); one
that has a lease gets a NAK when it next renews.
Entries are a MAC address (aa:bb:cc:dd:ee:ff), a prefix for one maker's
devices (aa:bb:cc:*) or a client identifier (id 01aabbccddeeff).
steward access mode iot allow-list
steward access allow iot aa:bb:cc:* --comment "Sonos speakers"
steward access deny office 3c:22:fb:11:22:33 --comment "old laptop"`,
}
cmd.AddCommand(&cobra.Command{
Use: "show SUBNET", Short: "Show a subnet's access mode and lists", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var s api.Subnet
if err := c.Get(ctx(), subnetPath(args[0]), nil, &s); err != nil {
return err
}
return output(s.Subnet, func(w io.Writer) {
fmt.Fprintf(w, "Access:\t%s\n", accessText(s.Subnet))
for _, l := range []struct {
name string
entries []model.ClientMatch
}{{"Allow", s.Allow}, {"Deny", s.Deny}} {
if len(l.entries) == 0 {
fmt.Fprintf(w, "%s:\t-\n", l.name)
}
for i, m := range l.entries {
label := l.name + ":"
if i > 0 {
label = ""
}
fmt.Fprintf(w, "%s\t%s\t%s\n", label, m.Match, m.Comment)
}
}
})
},
})
cmd.AddCommand(&cobra.Command{
Use: "mode SUBNET deny-list|allow-list", Short: "Answer everyone not denied, or only the allowed", Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return patchAccess(args[0], func(s *model.Subnet, in *api.SubnetInput) error {
in.Access = &args[1]
return nil
})
},
})
var comment string
for _, allow := range []bool{true, false} {
use, short := "deny SUBNET ENTRY...", "Put clients on a subnet's deny list"
if allow {
use, short = "allow SUBNET ENTRY...", "Put clients on a subnet's allow list"
}
c := &cobra.Command{
Use: use, Short: short, Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return patchAccess(args[0], func(s *model.Subnet, in *api.SubnetInput) error {
list := append([]model.ClientMatch{}, s.Deny...)
if allow {
list = append([]model.ClientMatch{}, s.Allow...)
}
entries:
for _, a := range args[1:] {
m, err := model.ParseClientMatch(a)
if err != nil {
return err
}
for i := range list {
if list[i].Match == m {
if cmd.Flags().Changed("comment") {
list[i].Comment = comment
}
continue entries
}
}
list = append(list, model.ClientMatch{Match: m, Comment: comment})
}
if allow {
in.Allow = &list
} else {
in.Deny = &list
}
return nil
})
},
}
c.Flags().StringVar(&comment, "comment", "", "what the entry is (\"Sonos speakers\")")
cmd.AddCommand(c)
}
cmd.AddCommand(&cobra.Command{
Use: "remove SUBNET ENTRY...", Short: "Take clients off a subnet's allow and deny lists", Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return patchAccess(args[0], func(s *model.Subnet, in *api.SubnetInput) error {
drop := map[string]bool{}
for _, a := range args[1:] {
m, err := model.ParseClientMatch(a)
if err != nil {
return err
}
drop[m] = true
}
allow, deny := []model.ClientMatch{}, []model.ClientMatch{}
for _, m := range s.Allow {
if !drop[m.Match] {
allow = append(allow, m)
}
}
for _, m := range s.Deny {
if !drop[m.Match] {
deny = append(deny, m)
}
}
if len(allow) == len(s.Allow) && len(deny) == len(s.Deny) {
return fmt.Errorf("none of them is on the lists of %s", s.Label())
}
in.Allow, in.Deny = &allow, &deny
return nil
})
},
})
return cmd
}
// patchAccess changes a subnet's access settings from its current ones.
func patchAccess(ref string, edit func(s *model.Subnet, in *api.SubnetInput) error) error {
c, err := newClient()
if err != nil {
return err
}
var cur api.Subnet
if err := c.Get(ctx(), subnetPath(ref), nil, &cur); err != nil {
return err
}
in := api.SubnetInput{}
if err := edit(cur.Subnet, &in); err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", subnetPath(ref), nil, in, &resp); err != nil {
return err
}
return report(&resp)
}
// accessText sums up who a subnet answers.
func accessText(s *model.Subnet) string {
t := fmt.Sprintf("deny-list: everyone but %d denied", len(s.Deny))
if s.Access == model.AccessAllowList {
t = fmt.Sprintf("allow-list: %d allowed, clients with a reservation, and %d denied", len(s.Allow), len(s.Deny))
}
return t
}
+57 -8
View File
@@ -170,28 +170,37 @@ its client ID (option 61): either one gets it the address. --by mac or
func activityCmd() *cobra.Command {
var mac, subnet, iface string
var limit int
var follow bool
var follow, details bool
cmd := &cobra.Command{
Use: "activity",
Short: "Show recent DHCP messages (requests and answers)",
Long: `Shows the latest DHCP messages Steward received and sent, newest last.
-f keeps watching. Handy to see why a device doesn't get an address:
steward activity --mac aa:bb:cc:dd:ee:01 -f`,
steward activity --mac aa:bb:cc:dd:ee:01 -f
--details (-d) shows every option of each message in words: what the
client asked for (its parameter request list), its client ID, and what
Steward sent back. 'steward activity show N' shows one message (N is the
number at the start of its line).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var last time.Time
detailsQ := ""
if details {
detailsQ = "1"
}
var last uint64
tw := func(evs []api.Event) {
sort.Slice(evs, func(i, j int) bool { return evs[i].Time.Before(evs[j].Time) })
sort.Slice(evs, func(i, j int) bool { return evs[i].ID < evs[j].ID })
for _, e := range evs {
if !e.Time.After(last) {
if e.ID <= last {
continue
}
last = e.Time
last = e.ID
detail := ""
if e.Hostname != "" {
detail = " " + e.Hostname
@@ -199,12 +208,16 @@ func activityCmd() *cobra.Command {
if e.Detail != "" {
detail += " (" + e.Detail + ")"
}
fmt.Printf("%s %-8s %-13s %-17s %-15s%s\n", e.Time.Local().Format("15:04:05.000"), e.Interface, e.Type, orDash(e.MAC), orDash(e.IP), detail)
fmt.Printf("%-6s %s %-8s %-13s %-17s %-15s%s\n", fmt.Sprintf("#%d", e.ID), e.Time.Local().Format("15:04:05.000"), e.Interface, e.Type, orDash(e.MAC), orDash(e.IP), detail)
if details {
printEventDetails(os.Stdout, e, " ")
fmt.Println()
}
}
}
for {
var evs []api.Event
if err := c.Get(ctx(), "/events", q("mac", mac, "subnet", subnet, "interface", iface, "limit", fmt.Sprint(limit)), &evs); err != nil {
if err := c.Get(ctx(), "/events", q("mac", mac, "subnet", subnet, "interface", iface, "limit", fmt.Sprint(limit), "details", detailsQ), &evs); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
@@ -227,9 +240,45 @@ func activityCmd() *cobra.Command {
f.StringVar(&iface, "interface", "", "only this interface's")
f.IntVar(&limit, "limit", 50, "at most this many")
f.BoolVarP(&follow, "follow", "f", false, "keep watching")
f.BoolVarP(&details, "details", "d", false, "show each message's fields and options")
cmd.AddCommand(&cobra.Command{
Use: "show N", Short: "Show one DHCP message with all its options", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var e api.Event
if err := c.Get(ctx(), "/events/"+strings.TrimPrefix(args[0], "#"), nil, &e); err != nil {
return err
}
return output(e, func(w io.Writer) {
dir := map[string]string{"in": "received from the client", "out": "sent by Steward"}[e.Direction]
fmt.Fprintf(w, "#%d %s on %s at %s", e.ID, e.Type, e.Interface, e.Time.Local().Format("2006-01-02 15:04:05.000"))
if dir != "" {
fmt.Fprintf(w, ", %s", dir)
}
fmt.Fprintln(w)
if e.Detail != "" {
fmt.Fprintf(w, "%s\n", e.Detail)
}
printEventDetails(w, e, " ")
})
},
})
return cmd
}
// printEventDetails writes a message's fields and options, one per line.
func printEventDetails(w io.Writer, e api.Event, indent string) {
for _, f := range e.Fields {
fmt.Fprintf(w, "%s%s: %s\n", indent, f.Name, f.Value)
}
for _, o := range e.Options {
fmt.Fprintf(w, "%soption %d %s: %s\n", indent, o.Code, o.Name, o.Value)
}
}
func settingsCmd() *cobra.Command {
cmd := &cobra.Command{Use: "settings", Short: "Server-wide settings and the options every subnet gets"}
cmd.AddCommand(&cobra.Command{
+1 -1
View File
@@ -173,7 +173,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
root.AddCommand(c)
}
}
add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd())
add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), accessCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd())
add("changes", historyCmd(), importCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
return root
+1
View File
@@ -93,6 +93,7 @@ func subnetCmd() *cobra.Command {
fmt.Fprintf(w, "Prefix:\t%s (mask %s)\n", s.Prefix, model.Mask(s.Prefix))
fmt.Fprintf(w, "Pools:\t%s\n", poolsText(s.Pools))
fmt.Fprintf(w, "Order:\t%s\n", orderText(s.Subnet))
fmt.Fprintf(w, "Access:\t%s\n", accessText(s.Subnet))
fmt.Fprintf(w, "Leased:\t%s, %d declined\n", usageText(s.Usage), s.Usage.Declined)
fmt.Fprintf(w, "Reservations:\t%d (%d with a lease now)\n", s.Usage.Reservations, s.Usage.ReservationsLeased)
lt := "the default"