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:
1 parent
b3c8329ae8
commit
3ad2f981d4
30 files changed
+1092
-37
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user