diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a8c7a9..79c642f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to Steward are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- **Allow and deny lists per subnet** (docs/subnets.md): a deny-list + subnet (the default) answers everyone not denied; an allow-list subnet + only clients allowed or with a reservation. 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 when it + next renews. `steward access`, the subnet's Access tab, `access`, + `allow` and `deny` in the API. +- **Every option of a DHCP message, in words:** ▸ on an Activity row, + `steward activity show N` or `--details`, and `GET /events/{id}`. What + the client asked for (its parameter request list, by name), its client + ID and host name, and what Steward sent back. + +### Upgrading from 0.1 + +- The database gets a new version (7), applied when stewardd starts; a + 0.1 stewardd can't open it afterwards, so take a backup first (`sudo + stewardd backup`). Every subnet stays a deny-list subnet with empty + lists, so nothing changes until you add entries. + ## [0.1.0] - 2026-10-18 The first version of Steward, a DHCPv4 server with a web UI, a REST API diff --git a/cmd/steward/access.go b/cmd/steward/access.go new file mode 100644 index 0000000..2190e6f --- /dev/null +++ b/cmd/steward/access.go @@ -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 +} diff --git a/cmd/steward/leases.go b/cmd/steward/leases.go index 21fb258..fd7e280 100644 --- a/cmd/steward/leases.go +++ b/cmd/steward/leases.go @@ -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{ diff --git a/cmd/steward/main.go b/cmd/steward/main.go index 018ac1c..1fb158e 100644 --- a/cmd/steward/main.go +++ b/cmd/steward/main.go @@ -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 diff --git a/cmd/steward/subnets.go b/cmd/steward/subnets.go index dd6598f..da1d710 100644 --- a/cmd/steward/subnets.go +++ b/cmd/steward/subnets.go @@ -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" diff --git a/docs/api.md b/docs/api.md index 082f3a3..979dec5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -99,6 +99,10 @@ Creating answers 201, other changes 200. "options": [{"option": "routers", "value": "10.0.10.1"}], "disabled": false, "comment": ""} ``` +`access` is `deny-list` (the default, left out) or `allow-list`; `allow` +and `deny` replace the lists, each `[{"match": "aa:bb:cc:*", "comment": +"Sonos"}]` with a MAC address, a prefix or `"id HEX"` +([subnets.md](subnets.md#access-allow-and-deny-lists)). `pools` and `options` replace the lists; the pools keep their order. A pool's `:ORDER` is its own address order. `pool_order` is `listed` or `random`; `address_order` is `sequential`, `reverse`, `random` or @@ -122,7 +126,8 @@ out. `lease_time` 0 means the settings'. | `GET /leases/{ip}` | one | | `DELETE /leases/{ip}` | forget it (audited) | | `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment", "by"}`, `by` being `mac`, `client_id` or `both` (the default) | -| `GET /events[?mac=&subnet=&interface=&limit=]` | the latest DHCP messages, newest first (the last 1000 are kept in memory). `subnet` (ID, name or prefix) keeps messages for its addresses, and those without an address on its interface | +| `GET /events[?mac=&subnet=&interface=&limit=&details=1]` | the latest DHCP messages, newest first (the last 1000 are kept in memory), each with an `id`, `direction` (`in` from the client, `out` from Steward) and `client_id`. `subnet` (ID, name or prefix) keeps messages for its addresses, and those without an address on its interface. `details=1` adds `fields` and `options` | +| `GET /events/{id}` | one message with `fields` (header fields that are set) and `options`, each `{"code", "name", "value"}` in words | ### History and import diff --git a/docs/cli.md b/docs/cli.md index c693864..4be6a79 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -82,6 +82,18 @@ one. An option Steward doesn't know: `--option 224:string=hello`. | `reservation enable\|disable RES` | use a reservation, or keep it but ignore it | | `reservation delete RES` | | +### access + +| Command | | +|---|---| +| `access show SUBNET` | the subnet's mode and its allow and deny lists | +| `access mode SUBNET deny-list\|allow-list` | answer every client not denied, or only the allowed (and reserved) | +| `access allow SUBNET ENTRY... [--comment C]` | add to the allow list (`aa:bb:cc:dd:ee:ff`, `aa:bb:cc:*` or `id HEX`); an entry it has gets the new comment | +| `access deny SUBNET ENTRY... [--comment C]` | add to the deny list | +| `access remove SUBNET ENTRY...` | take off both lists | + +See [subnets.md](subnets.md#access-allow-and-deny-lists). + ### lease | Command | | @@ -93,19 +105,39 @@ one. An option Steward doesn't know: `--option 224:string=hello`. ### activity -`steward activity [--mac MAC] [--subnet SUBNET] [--interface I] [--limit N] [-f]`: +`steward activity [--mac MAC] [--subnet SUBNET] [--interface I] [--limit N] [-d] [-f]`: the latest DHCP messages, received and sent, oldest first. `--subnet` takes an ID, name or prefix; a message without an address (a DISCOVER, "no address") shows for every subnet on its interface. `-f` keeps watching, which is useful while a device tries to get an address: ``` -11:02:13.120 eth0 DHCPDISCOVER 3c:22:fb:11:22:33 - adams-macbook -11:02:13.622 eth0 DHCPOFFER 3c:22:fb:11:22:33 10.0.10.150 adams-macbook -11:02:13.630 eth0 DHCPREQUEST 3c:22:fb:11:22:33 10.0.10.150 adams-macbook -11:02:13.631 eth0 DHCPACK 3c:22:fb:11:22:33 10.0.10.150 adams-macbook +#1042 11:02:13.120 eth0 DHCPDISCOVER 3c:22:fb:11:22:33 - adams-macbook +#1043 11:02:13.622 eth0 DHCPOFFER 3c:22:fb:11:22:33 10.0.10.150 adams-macbook +#1044 11:02:13.630 eth0 DHCPREQUEST 3c:22:fb:11:22:33 10.0.10.150 adams-macbook +#1045 11:02:13.631 eth0 DHCPACK 3c:22:fb:11:22:33 10.0.10.150 adams-macbook ``` +Each line starts with the message's number. `-d` (`--details`) adds every +field and option of each message in words, and `steward activity show N` +shows one: what the client asked for (its parameter request list, by +name), its client identifier and host name, and what Steward sent back +(lease time, routers, DNS servers...): + +``` +$ steward activity show 1042 +#1042 DHCPDISCOVER on eth0 at 2026-10-18 11:02:13.120, received from the client + transaction ID: 0x3a1c99f0 + client hardware address: 3c:22:fb:11:22:33 + flags: broadcast (the client wants replies broadcast) + option 53 message-type: DHCPDISCOVER + option 61 client-identifier: 01:3c:22:fb:11:22:33 (type 1: the MAC address 3c:22:fb:11:22:33) + option 55 parameter-request-list: subnet-mask (1), routers (3), domain-name-servers (6), domain-name (15), v4-captive-portal (114) + option 12 host-name: adams-macbook +``` + +In the web UI, ▸ on an Activity row opens the same. + `NO ADDRESS` means the pools on that interface are full, and `CONFLICT` an address that answered the ping check. A `DHCPNAK` says why it was sent. diff --git a/docs/subnets.md b/docs/subnets.md index 2ef8391..87f04ae 100644 --- a/docs/subnets.md +++ b/docs/subnets.md @@ -201,6 +201,45 @@ renewed, and is kept across restarts. `steward lease delete IP` frees an address at once. A client still using it gets a NAK when it next renews, then asks for a new address. +## Access: allow and deny lists + +A subnet decides who it answers: + +* **deny-list** (the default): every client except those on its deny + list; +* **allow-list**: only clients on its allow list or with a reservation in + it, and not on its deny list. + +The deny list counts in both modes, and wins over a reservation; so an +allow-list subnet can allow a maker's devices by prefix and still deny one +of them. Switching the mode keeps both lists. + +Entries are a MAC address (`aa:bb:cc:dd:ee:ff`), a prefix for one maker's +devices (`aa:bb:cc:*`, 1 to 5 bytes) or a client identifier +(`id 01aabbccddeeff`), each with an optional comment: + +```sh +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" +steward access remove office 3c:22:fb:11:22:33 +steward access show iot +``` + +In the web UI it is a subnet's **Access** tab; a DENIED line in Activity +has an **Allow** button that puts that client on the subnet's allow list. + +A refused client gets **no answer**: its DISCOVER shows as `DENIED` in the +activity log, with the reason. A client refused after it got its lease +gets a **NAK** when it next renews, so it stops using the address then +(within half its lease time). + +This is a control, not security: anyone can copy an allowed MAC address. +Phones and laptops also use a **private MAC address** per network by +default (iOS, Android, Windows), different from the one on their label; +on an allow-list subnet, turn that off for the network on the device, or +allow the private address it shows (Activity lists it). + ## Authority Steward is **authoritative** for its interfaces. A client that asks to diff --git a/docs/validation.md b/docs/validation.md index b646729..0a7326f 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -63,6 +63,11 @@ These apply to options anywhere (settings, subnets, reservations). | `reservation_network_address` | error | the network or broadcast address of its subnet | | `reservation_ip_taken` | error | the address is reserved already | | `reservation_client_taken` | error | the client has a reservation in this subnet already (one per subnet; a device on two VLANs may have one in each) | +| `reservation_denied` | warning | a reservation's client is on its subnet's deny list, so it gets no address | +| `access_mode` | error | a subnet's access is not `deny-list` or `allow-list` | +| `access_entry` | error | an allow or deny list entry that isn't a MAC address, a prefix (`aa:bb:cc:*`) or `id HEX` | +| `access_entry_duplicate` | error | an entry is on the same list twice | +| `allow_list_empty` | warning | an allow-list subnet with no allow list entries and no reservations: nobody gets an address | | `reservation_hostname` | error | not a host name (letters, digits and `-`, labels up to 63 characters) | | `reservation_server_address` | warning, host | the server's own address | diff --git a/internal/api/types.go b/internal/api/types.go index 3a12e65..522c828 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -6,6 +6,7 @@ import ( "encoding/json" "time" + "steward/internal/dhcp4" "steward/internal/model" "steward/internal/validate" "steward/internal/version" @@ -81,12 +82,17 @@ type SubnetInput struct { Pools *[]string `json:"pools,omitempty"` // PoolOrder is listed or random; AddressOrder sequential, reverse, // random or "" (the settings'). - PoolOrder *string `json:"pool_order,omitempty"` - AddressOrder *string `json:"address_order,omitempty"` - LeaseTime *int `json:"lease_time,omitempty"` - Options *[]OptionInput `json:"options,omitempty"` - Disabled *bool `json:"disabled,omitempty"` - Comment *string `json:"comment,omitempty"` + PoolOrder *string `json:"pool_order,omitempty"` + AddressOrder *string `json:"address_order,omitempty"` + // Access is deny-list or allow-list; Allow and Deny replace the + // lists (entries: a MAC address, aa:bb:cc:* or "id HEX"). + Access *string `json:"access,omitempty"` + Allow *[]model.ClientMatch `json:"allow,omitempty"` + Deny *[]model.ClientMatch `json:"deny,omitempty"` + LeaseTime *int `json:"lease_time,omitempty"` + Options *[]OptionInput `json:"options,omitempty"` + Disabled *bool `json:"disabled,omitempty"` + Comment *string `json:"comment,omitempty"` } // ReservationInput creates or changes a reservation (unset fields stay). @@ -255,6 +261,18 @@ type Event struct { IP string `json:"ip,omitempty"` Hostname string `json:"hostname,omitempty"` Detail string `json:"detail,omitempty"` + // ID numbers the events since stewardd started. + ID uint64 `json:"id"` + // Direction is "in" for a client's message, "out" for Steward's + // answer, "" for an event of Steward's own (CONFLICT, NO ADDRESS). + Direction string `json:"direction,omitempty"` + ClientID string `json:"client_id,omitempty"` + // SubnetID is the subnet that refused a client (DENIED). + SubnetID int64 `json:"subnet_id,omitempty"` + // Fields and Options are the message, in words (GET /events/{id}, or + // ?details=1). + Fields []dhcp4.Field `json:"fields,omitempty"` + Options []dhcp4.Field `json:"options,omitempty"` } // ImportResult reports a Kea import. diff --git a/internal/dhcp4/describe.go b/internal/dhcp4/describe.go new file mode 100644 index 0000000..439599d --- /dev/null +++ b/internal/dhcp4/describe.go @@ -0,0 +1,167 @@ +package dhcp4 + +import ( + "encoding/binary" + "fmt" + "strconv" + "strings" + "time" +) + +// Field is a message's header field or option, in words: for the activity +// log's details. +type Field struct { + // Code is the option's number (0 for a header field). + Code int `json:"code,omitempty"` + Name string `json:"name"` + Value string `json:"value"` +} + +// protocol are the options the server and clients set themselves, which +// the catalog leaves out because they can't be configured. +var protocol = map[byte]Def{ + 1: {Name: "subnet-mask", Type: TypeIP}, + 12: {Name: "host-name", Type: TypeString}, + 50: {Name: "requested-address", Type: TypeIP}, + 51: {Name: "lease-time", Type: "seconds"}, + 52: {Name: "option-overload", Type: TypeU8}, + 53: {Name: "message-type", Type: "message-type"}, + 54: {Name: "server-identifier", Type: TypeIP}, + 55: {Name: "parameter-request-list", Type: "codes"}, + 56: {Name: "message", Type: TypeString}, + 57: {Name: "max-message-size", Type: TypeU16}, + 58: {Name: "renewal-time", Type: "seconds"}, + 59: {Name: "rebinding-time", Type: "seconds"}, + 61: {Name: "client-identifier", Type: "client-id"}, + 77: {Name: "user-class", Type: TypeHex}, + 81: {Name: "client-fqdn", Type: "fqdn"}, + 82: {Name: "relay-agent-information", Type: TypeHex}, + 116: {Name: "auto-configure", Type: TypeU8}, + 118: {Name: "subnet-selection", Type: TypeIP}, +} + +// optionDef is an option's definition for display. +func optionDef(code byte) Def { + if d, ok := protocol[code]; ok { + d.Code = code + return d + } + return ByCode(code) +} + +// OptionName is an option's name: "routers", "parameter-request-list", +// "option-224". +func OptionName(code byte) string { return optionDef(code).Name } + +// DescribeOption is an option's value in words. +func DescribeOption(code byte, b []byte) string { + d := optionDef(code) + switch d.Type { + case "seconds": + if len(b) == 4 { + s := binary.BigEndian.Uint32(b) + return fmt.Sprintf("%d s (%s)", s, shortDuration(time.Duration(s)*time.Second)) + } + case "message-type": + if len(b) == 1 { + return MessageType(b[0]).String() + } + case "codes": + names := make([]string, len(b)) + for i, c := range b { + names[i] = OptionName(c) + " (" + strconv.Itoa(int(c)) + ")" + } + return strings.Join(names, ", ") + case "client-id": + hexed := Decode(TypeHex, b) + switch { + case len(b) == 7 && b[0] == 1: + return hexed + " (type 1: the MAC address " + Decode(TypeHex, b[1:]) + ")" + case len(b) > 5 && b[0] == 255: + return hexed + " (RFC 4361: IAID " + Decode(TypeHex, b[1:5]) + " and a DUID)" + } + return hexed + case "fqdn": + // Flags, two obsolete RCODEs, then the name: in DNS wire format + // when the E flag is set, else as text. + if len(b) >= 3 { + name := string(b[3:]) + if b[0]&0x04 != 0 { + if names, err := decodeNames(b[3:]); err == nil && len(names) > 0 { + name = names[0] + } + } + var flags []string + if b[0]&0x01 != 0 { + flags = append(flags, "server updates the A record") + } + if b[0]&0x08 != 0 { + flags = append(flags, "no DNS updates") + } + if len(flags) == 0 { + flags = append(flags, "client updates the A record") + } + return name + " (" + strings.Join(flags, ", ") + ")" + } + default: + return Decode(d.Type, b) + } + return Decode(TypeHex, b) +} + +// shortDuration writes 43200s as "12h", 90s as "1m30s", a week as "7d". +func shortDuration(d time.Duration) string { + if d == 0 { + return "0s" + } + var out string + for _, u := range []struct { + d time.Duration + name string + }{{24 * time.Hour, "d"}, {time.Hour, "h"}, {time.Minute, "m"}, {time.Second, "s"}} { + if n := d / u.d; n > 0 { + out += strconv.Itoa(int(n)) + u.name + d -= n * u.d + } + } + return out +} + +// Describe lists a message's header fields that are set, and its options +// in the order they are in the message. +func (p *Packet) Describe() (fields, options []Field) { + add := func(name, value string) { fields = append(fields, Field{Name: name, Value: value}) } + add("transaction ID", fmt.Sprintf("0x%08x", p.XID)) + add("client hardware address", p.MAC().String()) + if !IsZero(p.CIAddr) { + add("client address (ciaddr)", p.CIAddr.String()) + } + if !IsZero(p.YIAddr) { + add("your address (yiaddr)", p.YIAddr.String()) + } + if !IsZero(p.SIAddr) { + add("next server (siaddr)", p.SIAddr.String()) + } + if !IsZero(p.GIAddr) { + add("relay agent (giaddr)", p.GIAddr.String()) + } + if p.Broadcast() { + add("flags", "broadcast (the client wants replies broadcast)") + } + if p.Secs != 0 { + add("seconds since the client began", strconv.Itoa(int(p.Secs))) + } + if p.Hops != 0 { + add("hops", strconv.Itoa(int(p.Hops))) + } + if p.SName != "" { + add("server name (sname)", p.SName) + } + if p.File != "" { + add("boot file (file)", p.File) + } + for _, o := range p.Options { + options = append(options, Field{Code: int(o.Code), Name: OptionName(o.Code), Value: DescribeOption(o.Code, o.Data)}) + } + return fields, options +} diff --git a/internal/dhcp4/describe_test.go b/internal/dhcp4/describe_test.go new file mode 100644 index 0000000..6eb1220 --- /dev/null +++ b/internal/dhcp4/describe_test.go @@ -0,0 +1,36 @@ +package dhcp4 + +import ( + "net" + "strings" + "testing" +) + +func TestDescribe(t *testing.T) { + p := NewRequest(Discover, net.HardwareAddr{0xaa, 0, 0, 0, 0, 1}, 0x1234) + p.Set(OptParamRequest, []byte{1, 3, 6, 224}) + p.Set(OptClientID, []byte{1, 0xaa, 0, 0, 0, 0, 1}) + p.Set(OptLeaseTime, U32(5400)) + p.Set(81, []byte{0x05, 0, 0, 6, 'l', 'a', 'p', 't', 'o', 'p', 0}) + fields, opts := p.Describe() + got := map[string]string{} + for _, f := range append(fields, opts...) { + got[f.Name] = f.Value + } + for name, want := range map[string]string{ + "transaction ID": "0x00001234", + "message-type": "DHCPDISCOVER", + "parameter-request-list": "subnet-mask (1), routers (3), domain-name-servers (6), option-224 (224)", + "client-identifier": "01:aa:00:00:00:00:01 (type 1: the MAC address aa:00:00:00:00:01)", + "lease-time": "5400 s (1h30m)", + "client-fqdn": "laptop (server updates the A record)", + "client hardware address": "aa:00:00:00:00:01", + } { + if !strings.EqualFold(got[name], want) { + t.Errorf("%s: %q, want %q", name, got[name], want) + } + } + if shortDuration(7*24*3600e9) != "7d" || shortDuration(90e9) != "1m30s" || shortDuration(43200e9) != "12h" { + t.Error("shortDuration") + } +} diff --git a/internal/dhcpd/config.go b/internal/dhcpd/config.go index 4842a80..6cbcb51 100644 --- a/internal/dhcpd/config.go +++ b/internal/dhcpd/config.go @@ -109,6 +109,12 @@ func (c *config) reservationFor(subnetID int64, cl client) *model.Reservation { return m["mac "+cl.mac] } +// admits says whether the subnet answers the client (its allow and deny +// lists), and if not why. +func (c *config) admits(sub *subnet, cl client) (bool, string) { + return sub.Admits(cl.mac, cl.id, c.reservationFor(sub.ID, cl) != nil) +} + // subnetFor finds the interface's subnet holding a. func (c *config) subnetFor(iface string, a netip.Addr) *subnet { for _, s := range c.byIface[iface] { diff --git a/internal/dhcpd/handle.go b/internal/dhcpd/handle.go index 3233dcc..3d87bba 100644 --- a/internal/dhcpd/handle.go +++ b/internal/dhcpd/handle.go @@ -41,7 +41,8 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out } cl := client{mac: p.MAC().String(), id: p.ClientID()} t := p.Type() - ev := Event{Time: s.now(), Interface: iface, Type: t.String(), MAC: cl.mac, Hostname: p.HostName()} + ev := Event{Time: s.now(), Interface: iface, Type: t.String(), MAC: cl.mac, Hostname: p.HostName(), Direction: "in", ClientID: cl.id} + ev.Fields, ev.Options = p.Describe() if a, ok := p.IPOption(dhcp4.OptRequestedIP); ok { ev.IP = a.String() } else if !dhcp4.IsZero(p.CIAddr) { @@ -64,7 +65,13 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out return nil } if out != nil { - ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName()} + ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName(), Direction: "out", ClientID: cl.id} + ev.Fields, ev.Options = out.Packet.Describe() + if out.Dst.IsValid() { + ev.Fields = append(ev.Fields, dhcp4.Field{Name: "sent to", Value: out.Dst.String() + " (unicast)"}) + } else { + ev.Fields = append(ev.Fields, dhcp4.Field{Name: "sent to", Value: "255.255.255.255 (broadcast on " + iface + ")"}) + } if !dhcp4.IsZero(out.Packet.YIAddr) { ev.IP = out.Packet.YIAddr.String() } @@ -79,6 +86,22 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out // ---- DISCOVER func (s *Server) discover(ctx context.Context, cfg *config, iface string, subs []*subnet, p *dhcp4.Packet, cl client) *Outbound { + // Only the subnets whose access lists let the client in. + var allowed []*subnet + var refusedBy *subnet + why := "" + for _, sub := range subs { + if ok, reason := cfg.admits(sub, cl); ok { + allowed = append(allowed, sub) + } else { + refusedBy, why = sub, reason + } + } + if len(allowed) == 0 { + s.denied(iface, refusedBy, cl, why) + return nil + } + subs = allowed requested, _ := p.IPOption(dhcp4.OptRequestedIP) for try := 0; try < 8; try++ { ip, sub, fresh := s.choose(cfg, iface, subs, cl, requested) @@ -238,6 +261,11 @@ func (s *Server) conflict(ctx context.Context, cfg *config, iface string, sub *s s.events.add(Event{Time: now, Interface: iface, Type: "CONFLICT", IP: ip.String(), Detail: "answered a ping; held back"}) } +// denied records a client a subnet's access lists refused. +func (s *Server) denied(iface string, sub *subnet, cl client, why string) { + s.events.add(Event{Time: s.now(), Interface: iface, Type: "DENIED", MAC: cl.mac, ClientID: cl.id, SubnetID: sub.ID, Detail: "no answer: the client is " + why}) +} + // ---- REQUEST func (s *Server) request(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client) *Outbound { @@ -277,6 +305,11 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa if sub == nil { return s.nak(cfg, iface, p, nil, fmt.Sprintf("%s does not belong on %s", ip, iface)) } + // A client refused since it got its lease: a NAK makes it start over + // at once (its DISCOVER then gets no answer). + if ok, why := cfg.admits(sub, cl); !ok { + return s.nak(cfg, iface, p, sub, "this client is "+why) + } now := s.now() own := s.ownAddrs(iface) s.mu.Lock() @@ -403,6 +436,10 @@ func (s *Server) inform(cfg *config, iface string, p *dhcp4.Packet, cl client) * if sub == nil { return nil } + if ok, why := cfg.admits(sub, cl); !ok { + s.denied(iface, sub, cl, why) + return nil + } sid, err := s.serverAddr(iface, sub) if err != nil { return nil diff --git a/internal/dhcpd/handle_test.go b/internal/dhcpd/handle_test.go index f0fb9a0..f955d25 100644 --- a/internal/dhcpd/handle_test.go +++ b/internal/dhcpd/handle_test.go @@ -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) + } +} diff --git a/internal/dhcpd/server.go b/internal/dhcpd/server.go index 1d2def2..3b96171 100644 --- a/internal/dhcpd/server.go +++ b/internal/dhcpd/server.go @@ -15,6 +15,7 @@ import ( "sync/atomic" "time" + "steward/internal/dhcp4" "steward/internal/model" ) @@ -324,6 +325,18 @@ type Event struct { IP string `json:"ip,omitempty"` Hostname string `json:"hostname,omitempty"` Detail string `json:"detail,omitempty"` + // ID numbers the events since stewardd started. + ID uint64 `json:"id"` + // Direction is "in" for a client's message, "out" for Steward's + // answer, "" for an event of Steward's own (CONFLICT, NO ADDRESS). + Direction string `json:"direction,omitempty"` + ClientID string `json:"client_id,omitempty"` + // SubnetID is the subnet that refused a client (DENIED). + SubnetID int64 `json:"subnet_id,omitempty"` + // Fields and Options are the message, in words (GET /events/{id}, or + // ?details=1). + Fields []dhcp4.Field `json:"fields,omitempty"` + Options []dhcp4.Field `json:"options,omitempty"` } type events struct { @@ -331,11 +344,14 @@ type events struct { max int list []Event counts map[string]uint64 + lastID uint64 } func (e *events) add(ev Event) { e.mu.Lock() defer e.mu.Unlock() + e.lastID++ + ev.ID = e.lastID e.list = append(e.list, ev) if len(e.list) > e.max { e.list = append([]Event(nil), e.list[len(e.list)-e.max:]...) @@ -362,6 +378,18 @@ func (s *Server) Events(limit int, keep func(Event) bool) []Event { return out } +// Event returns one event, with its details. +func (s *Server) Event(id uint64) (Event, bool) { + s.events.mu.Lock() + defer s.events.mu.Unlock() + for i := len(s.events.list) - 1; i >= 0; i-- { + if s.events.list[i].ID == id { + return s.events.list[i], true + } + } + return Event{}, false +} + // Counts returns how many messages of each type were seen and sent since // the start. func (s *Server) Counts() map[string]uint64 { diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 00c639d..9cad70c 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -436,6 +436,15 @@ func Normalize(st *model.State) { if s.PoolOrder == model.PoolsListed { s.PoolOrder = "" } + if s.Access == model.AccessDenyList { + s.Access = "" + } + if len(s.Allow) == 0 { + s.Allow = nil + } + if len(s.Deny) == 0 { + s.Deny = nil + } } for _, r := range st.Reservations { r.Options = normOptions(r.Options) diff --git a/internal/model/access.go b/internal/model/access.go new file mode 100644 index 0000000..ba74668 --- /dev/null +++ b/internal/model/access.go @@ -0,0 +1,93 @@ +package model + +import ( + "fmt" + "strings" +) + +// Subnet access modes: who a subnet answers. +const ( + // AccessDenyList answers every client except those on the deny list + // (the default, stored as ""). + AccessDenyList = "deny-list" + // AccessAllowList answers only clients on the allow list or with a + // reservation in the subnet, and not on the deny list. + AccessAllowList = "allow-list" +) + +// ClientMatch is an entry of a subnet's allow or deny list: a MAC address +// ("aa:bb:cc:dd:ee:ff"), a MAC address prefix for a maker's devices +// ("aa:bb:cc:*"), or a client identifier ("id 01aabbccddeeff"). +type ClientMatch struct { + Match string `json:"match"` + Comment string `json:"comment,omitempty"` +} + +// ParseClientMatch reads and normalizes an allow or deny list entry. +func ParseClientMatch(s string) (string, error) { + s = strings.ToLower(strings.TrimSpace(s)) + if rest, ok := strings.CutPrefix(s, "id"); ok && (strings.HasPrefix(rest, " ") || strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, "=")) { + id, err := NormalizeClientID(rest[1:]) + if err != nil { + return "", err + } + return "id " + id, nil + } + if prefix, ok := strings.CutSuffix(s, "*"); ok { + prefix = strings.TrimRight(strings.ReplaceAll(prefix, "-", ":"), ":") + parts := strings.Split(prefix, ":") + if prefix == "" || len(parts) > 5 { + return "", fmt.Errorf("%q: a MAC address prefix has 1 to 5 bytes, as aa:bb:cc:*", s) + } + for i, p := range parts { + if len(p) == 1 { + p = "0" + p + } + if len(p) != 2 || strings.Trim(p, "0123456789abcdef") != "" { + return "", fmt.Errorf("%q: a MAC address prefix is written as aa:bb:cc:*", s) + } + parts[i] = p + } + return strings.Join(parts, ":") + ":*", nil + } + mac, err := NormalizeMAC(s) + if err != nil { + return "", fmt.Errorf("%q is not a MAC address (aa:bb:cc:dd:ee:ff), a prefix (aa:bb:cc:*) or a client ID (id 01aabbccddeeff)", s) + } + return mac, nil +} + +// Matches reports whether the entry is the client's: mac as +// "aa:bb:cc:dd:ee:ff", id the client identifier in hex ("" for none). +func (m ClientMatch) Matches(mac, id string) bool { + switch { + case strings.HasPrefix(m.Match, "id "): + return id != "" && m.Match[3:] == id + case strings.HasSuffix(m.Match, "*"): + return mac != "" && strings.HasPrefix(mac, m.Match[:len(m.Match)-1]) + } + return mac != "" && m.Match == mac +} + +// Admits says whether the subnet answers a client, and if not why. resv +// tells whether the client has an (enabled) reservation in the subnet. +func (s *Subnet) Admits(mac, id string, resv bool) (bool, string) { + for _, m := range s.Deny { + if m.Matches(mac, id) { + why := "on the deny list of " + s.Label() + if m.Comment != "" { + why += " (" + m.Comment + ")" + } + return false, why + } + } + if s.Access != AccessAllowList || resv { + return true, "" + } + for _, m := range s.Allow { + if m.Matches(mac, id) { + return true, "" + } + } + return false, "not on the allow list of " + s.Label() +} diff --git a/internal/model/config.go b/internal/model/config.go index 5430fb7..d655eaa 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -149,6 +149,12 @@ type Subnet struct { // LeaseTime overrides the settings' lease time (0: inherit). LeaseTime int `json:"lease_time"` Options []OptionValue `json:"options"` + // Access is who the subnet answers: everyone not on Deny ("", a + // deny-list subnet) or, as an allow-list subnet, only clients on + // Allow or with a reservation, and not on Deny (access.go). + Access string `json:"access,omitempty"` + Allow []ClientMatch `json:"allow,omitempty"` + Deny []ClientMatch `json:"deny,omitempty"` // Disabled subnets keep their configuration and leases but get no // answers. Disabled bool `json:"disabled"` diff --git a/internal/server/h_config.go b/internal/server/h_config.go index fa7ac10..125ebd3 100644 --- a/internal/server/h_config.go +++ b/internal/server/h_config.go @@ -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 } diff --git a/internal/server/h_leases.go b/internal/server/h_leases.go index f41f342..8b9c83b 100644 --- a/internal/server/h_leases.go +++ b/internal/server/h_leases.go @@ -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 { diff --git a/internal/server/routes.go b/internal/server/routes.go index 8542069..41382ab 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 31f9000..3a26274 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) + } +} diff --git a/internal/store/state.go b/internal/store/state.go index ae89fc1..1f4cade 100644 --- a/internal/store/state.go +++ b/internal/store/state.go @@ -25,16 +25,16 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) { if st.Settings.Options == nil { st.Settings.Options = []model.OptionValue{} } - rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, disabled, comment FROM subnets ORDER BY id`) + rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, access, allow_list, deny_list, disabled, comment FROM subnets ORDER BY id`) if err != nil { return nil, err } defer rows.Close() for rows.Next() { sn := &model.Subnet{} - var prefix, pools, opts string + var prefix, pools, opts, allow, deny string var disabled int - if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.PoolOrder, &sn.AddressOrder, &sn.LeaseTime, &opts, &disabled, &sn.Comment); err != nil { + if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.PoolOrder, &sn.AddressOrder, &sn.LeaseTime, &opts, &sn.Access, &allow, &deny, &disabled, &sn.Comment); err != nil { return nil, err } sn.Prefix, _ = netip.ParsePrefix(prefix) @@ -45,6 +45,18 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) { if err := json.Unmarshal([]byte(opts), &sn.Options); err != nil { return nil, fmt.Errorf("subnet %d options: %w", sn.ID, err) } + if err := json.Unmarshal([]byte(allow), &sn.Allow); err != nil { + return nil, fmt.Errorf("subnet %d allow list: %w", sn.ID, err) + } + if err := json.Unmarshal([]byte(deny), &sn.Deny); err != nil { + return nil, fmt.Errorf("subnet %d deny list: %w", sn.ID, err) + } + if len(sn.Allow) == 0 { + sn.Allow = nil + } + if len(sn.Deny) == 0 { + sn.Deny = nil + } st.Subnets = append(st.Subnets, sn) } if err := rows.Err(); err != nil { @@ -146,10 +158,12 @@ func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error { if err := json.Unmarshal(c.After, &sn); err != nil { return err } - _, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?,?,?) + _, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, access, allow_list, deny_list, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, interface = excluded.interface, prefix = excluded.prefix, pools = excluded.pools, - pool_order = excluded.pool_order, address_order = excluded.address_order, lease_time = excluded.lease_time, options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`, - sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.PoolOrder, sn.AddressOrder, sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment) + pool_order = excluded.pool_order, address_order = excluded.address_order, lease_time = excluded.lease_time, options = excluded.options, + access = excluded.access, allow_list = excluded.allow_list, deny_list = excluded.deny_list, disabled = excluded.disabled, comment = excluded.comment`, + sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.PoolOrder, sn.AddressOrder, sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), + sn.Access, jsonText(nonNilMatches(sn.Allow)), jsonText(nonNilMatches(sn.Deny)), boolInt(sn.Disabled), sn.Comment) return err case model.KindReservation: if c.After == nil { @@ -176,6 +190,13 @@ func nonNil(p []model.Pool) []model.Pool { return p } +func nonNilMatches(m []model.ClientMatch) []model.ClientMatch { + if m == nil { + return []model.ClientMatch{} + } + return m +} + func nonNilOpts(o []model.OptionValue) []model.OptionValue { if o == nil { return []model.OptionValue{} diff --git a/internal/store/store.go b/internal/store/store.go index b01c87d..fc37f30 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -210,6 +210,12 @@ ALTER TABLE subnets ADD COLUMN address_order TEXT NOT NULL DEFAULT ''; // 6: a reservation's own lease time. ` ALTER TABLE reservations ADD COLUMN lease_time INTEGER NOT NULL DEFAULT 0; +`, + // 7: who a subnet answers: its access mode and allow and deny lists. + ` +ALTER TABLE subnets ADD COLUMN access TEXT NOT NULL DEFAULT ''; +ALTER TABLE subnets ADD COLUMN allow_list TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE subnets ADD COLUMN deny_list TEXT NOT NULL DEFAULT '[]'; `, } diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 8b9be57..b05f740 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -164,6 +164,7 @@ func (c *checker) subnets(st *model.State, env *Env) { c.leaseTime(s.LeaseTime, obj) } c.pools(s, obj) + c.access(st, s, obj) c.options(s.Options, obj) c.routers(s, st.Settings.Options, obj) if env != nil && !s.Disabled && s.Interface != "" { @@ -206,6 +207,40 @@ func (c *checker) pools(s *model.Subnet, obj string) { } } +// access checks a subnet's access mode and lists. +func (c *checker) access(st *model.State, s *model.Subnet, obj string) { + if s.Access != "" && s.Access != model.AccessDenyList && s.Access != model.AccessAllowList { + c.add(Error, "access_mode", obj, "the access %q is not deny-list or allow-list", s.Access) + } + for _, list := range []struct { + name string + entries []model.ClientMatch + }{{"allow", s.Allow}, {"deny", s.Deny}} { + seen := map[string]bool{} + for _, m := range list.entries { + if norm, err := model.ParseClientMatch(m.Match); err != nil || norm != m.Match { + c.add(Error, "access_entry", obj, "the %s list entry %q is not a MAC address (aa:bb:cc:dd:ee:ff), a prefix (aa:bb:cc:*) or a client ID (id 01aabbccddeeff)", list.name, m.Match) + } else if seen[m.Match] { + c.add(Error, "access_entry_duplicate", obj, "%s is on the %s list twice", m.Match, list.name) + } + seen[m.Match] = true + } + } + reserved := 0 + for _, r := range st.Reservations { + if r.Disabled || !s.Prefix.Contains(r.IP) { + continue + } + reserved++ + if ok, _ := s.Admits(r.MAC, r.ClientID, true); !ok { + c.add(Warning, "reservation_denied", obj, "the client of reservation %s is on the deny list: it gets no address", r.Label()) + } + } + if s.Access == model.AccessAllowList && len(s.Allow) == 0 && reserved == 0 && !s.Disabled { + c.add(Warning, "allow_list_empty", obj, "the subnet answers only clients on its allow list or with a reservation, and it has neither: nobody gets an address") + } +} + // routers warns about a gateway outside the subnet: clients can't reach it. func (c *checker) routers(s *model.Subnet, global []model.OptionValue, obj string) { val := "" diff --git a/internal/web/static/assets/pages/activity.js b/internal/web/static/assets/pages/activity.js index f3a0aa5..5f51e3c 100644 --- a/internal/web/static/assets/pages/activity.js +++ b/internal/web/static/assets/pages/activity.js @@ -1,24 +1,52 @@ // Activity: the latest DHCP messages, refreshed every few seconds. -import { h, mount, input, select, debounce } from "../ui.js"; -import { get } from "../api.js"; +import { h, mount, input, select, debounce, toast, modal, errorBox } from "../ui.js"; +import { get, change, can } from "../api.js"; import { onLeave } from "../app.js"; import { subnetLabel } from "../widgets.js"; +// allowClient puts a refused client on its subnet's allow list. +async function allowClient(e) { + try { + const s = await get("/subnets/" + e.subnet_id); + if ((s.allow || []).some((m) => m.match === e.mac)) { toast(e.mac + " is already allowed on " + subnetLabel(s)); return; } + const allow = [...(s.allow || []), { match: e.mac, comment: "" }]; + await change("PATCH", "/subnets/" + e.subnet_id, { allow }); + toast(e.mac + " is allowed on " + subnetLabel(s) + "; it gets an address when it next asks"); + } catch (err) { modal("Not allowed", errorBox(err)); } +} + +// details shows a message's header fields and options in words. +function details(e) { + if (!e) return h("div", { class: "muted small" }, "Loading…"); + if (e.error) return h("div", { class: "msg err" }, e.error); + const who = e.direction === "in" ? "Received from the client" : "Sent by Steward"; + return h("div", { class: "event-detail" }, + h("div", { class: "muted small" }, who), + h("table", { class: "kv" }, h("tbody", null, + e.fields.map((f) => h("tr", null, h("th", null, f.name), h("td", { class: "mono" }, f.value))), + (e.options || []).map((o) => h("tr", null, h("th", null, o.name, h("span", { class: "muted small" }, " (" + o.code + ")")), + h("td", { class: "mono" }, o.value)))))); +} + export async function render(el, params, query) { const [ifaces, subnets] = await Promise.all([get("/interfaces"), get("/subnets")]); const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || ""); const mac = input({ placeholder: "MAC address", value: query.get("mac") || "", class: "mono" }); const iface = select([["", "all interfaces"], ...ifaces.filter((i) => i.subnets.length).map((i) => [i.name, i.name])], query.get("interface") || ""); const out = h("div"); + // Messages opened to show their options, kept across refreshes. + const open = new Map(); // id -> the event with its details, or null while loading let live = true; const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause"); const draw = async () => { const evs = await get("/events", { mac: mac.value.trim(), subnet: subnetSel.value, interface: iface.value, limit: 300 }); mount(out, evs.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" }, - h("thead", null, h("tr", null, ["Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))), + h("thead", null, h("tr", null, ["", "Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))), h("tbody", null, evs.map((e) => { const t = e.type.replace(/^DHCP/, ""); - return h("tr", null, + const row = h("tr", null, + h("td", null, e.direction ? h("button", { class: "small", title: open.has(e.id) ? "Hide the options" : "Show every option of this message", + onclick: () => toggle(e.id) }, open.has(e.id) ? "▾" : "▸") : ""), h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })), h("td", { class: "mono" }, e.interface), h("td", null, h("strong", { class: "type-" + t.split(" ")[0] }, t)), @@ -27,9 +55,19 @@ export async function render(el, params, query) { mac.value.trim() ? null : h("button", { class: "small reveal", title: "Show only this client's messages", onclick: () => { mac.value = e.mac; draw(); } }, "Only this")] : ""), h("td", { class: "mono" }, e.ip || ""), h("td", null, e.hostname || ""), - h("td", { class: "detail" }, e.detail || "")); + h("td", { class: "detail" }, e.detail || "", + e.type === "DENIED" && e.subnet_id && e.mac && (e.detail || "").includes("allow list") && can("config", true) + ? [" ", h("button", { class: "small", title: "Put this client on the subnet's allow list", onclick: () => allowClient(e) }, "Allow")] : null)); + return open.has(e.id) ? [row, h("tr", { class: "event-details" }, h("td"), h("td", { colspan: 7 }, details(open.get(e.id))))] : row; })))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || subnetSel.value || iface.value ? " that match." : "."))); }; + const toggle = async (id) => { + if (open.has(id)) { open.delete(id); draw(); return; } + open.set(id, null); + draw(); + try { open.set(id, await get("/events/" + id)); } catch (e) { open.set(id, { error: e.message }); } + draw(); + }; mac.addEventListener("input", debounce(draw, 300)); subnetSel.addEventListener("change", draw); iface.addEventListener("change", draw); diff --git a/internal/web/static/assets/pages/subnet.js b/internal/web/static/assets/pages/subnet.js index cc7a4b5..437627e 100644 --- a/internal/web/static/assets/pages/subnet.js +++ b/internal/web/static/assets/pages/subnet.js @@ -1,12 +1,12 @@ // A subnet's page: its leases, its reservations and its settings. -import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox } from "../ui.js"; +import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox, select, field } from "../ui.js"; import { get, change, can } from "../api.js"; import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText, orderText } from "../widgets.js"; import { leasesTable } from "./leases.js"; import { reservationsTable, reservationDialog } from "./reservations.js"; import { rerender } from "../app.js"; -const tabs = [["leases", "Leases"], ["reservations", "Reservations"], ["settings", "Settings"]]; +const tabs = [["leases", "Leases"], ["reservations", "Reservations"], ["access", "Access"], ["settings", "Settings"]]; export async function render(el, params) { const id = params.id; @@ -21,7 +21,8 @@ export async function render(el, params) { h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ", s.pools.length ? h("span", { class: "mono" }, s.pools.map(poolText).join(", ")) : "none", orderText(s) ? " · " + orderText(s) : "", - " · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")), + " · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default", + s.access === "allow-list" ? [" · ", badge("allow-list", "accent")] : "")), h("div", { class: "subnet-usage" }, usageBar(s.usage)), can("config", true) ? h("div", { class: "actions" }, h("button", { title: s.disabled ? "Answer this subnet's clients again" : "Stop answering this subnet's clients; its configuration and leases stay", @@ -31,9 +32,59 @@ export async function render(el, params) { body); if (tab === "leases") await leasesTab(body, s); else if (tab === "reservations") await reservationsTab(body, s); + else if (tab === "access") accessTab(body, s); else await settingsTab(body, s); } +// matchList edits an allow or deny list: entries with a comment each. +function matchList(entries, placeholder) { + const rows = h("div", { class: "pool-rows" }); + let list = []; + const addRow = (m) => { + const it = { match: input({ value: m ? m.match : "", placeholder, class: "mono" }), comment: input({ value: m ? m.comment || "" : "", placeholder: "what it is (optional)" }) }; + it.row = h("div", { class: "pool-row" }, it.match, it.comment, + h("button", { type: "button", class: "small", title: "Remove", onclick: () => { it.row.remove(); list = list.filter((x) => x !== it); } }, "✕")); + list.push(it); + rows.append(it.row); + return it.match; + }; + for (const m of entries || []) addRow(m); + const box = h("div", null, rows, h("button", { type: "button", class: "small", onclick: () => addRow(null).focus() }, "+ Add")); + box.value = () => list.map((it) => ({ match: it.match.value.trim(), comment: it.comment.value.trim() })).filter((m) => m.match); + return box; +} + +// accessTab: who the subnet answers. +function accessTab(el, s) { + const mode = select([["deny-list", "Deny-list: every client except those denied"], ["allow-list", "Allow-list: only clients allowed or with a reservation"]], s.access || "deny-list"); + const ph = "aa:bb:cc:dd:ee:ff, aa:bb:cc:* or id 01aabb…"; + const allow = matchList(s.allow, ph), deny = matchList(s.deny, ph); + // The allow list only counts in an allow-list subnet. + const sync = () => allow.classList.toggle("dimmed", mode.value !== "allow-list"); + mode.addEventListener("change", sync); + const err = h("div"); + const save = async (dry) => { + mount(err); + try { + const r = await change("PATCH", "/subnets/" + s.id, { access: mode.value, allow: allow.value(), deny: deny.value() }, { query: dry ? { dry_run: 1 } : undefined }); + if (!dry && r.applied) rerender(); + } catch (e) { mount(err, errorBox(e)); } + }; + const form = h("div", { class: "form-grid" }, + field("Who gets an address", mode), + field("Allow list", allow, "Used by an allow-list subnet. Clients with a reservation in the subnet are allowed too."), + field("Deny list", deny, "Never answered, in either mode, even with a reservation.")); + const write = can("config", true); + mount(el, h("div", { class: "panel" }, + h("p", { class: "muted" }, "Entries are a MAC address, a prefix for one maker's devices (aa:bb:cc:*) or a client identifier (id 01aabb…). A refused client gets no answer, and shows as DENIED in Activity; one with a lease gets a NAK when it next renews. Phones use a private MAC address per network unless told not to."), + form, err, + write ? h("div", { class: "actions", style: { marginTop: "14px" } }, + h("button", { class: "primary", onclick: () => save(false) }, "Save"), + h("button", { onclick: () => save(true), title: "Check the change without saving it" }, "Check")) : null)); + if (!write) readOnly(el.querySelector(".panel")); + sync(); +} + export async function toggleSubnet(s) { if (!s.disabled && !await confirmDialog("Disable subnet", "Stop answering the clients of " + subnetLabel(s) + "? They keep their addresses until their leases run out, then have none. The configuration and leases stay; Enable brings it back.", "Disable", "danger")) return; try { diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 660ec8e..6ff5e13 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -346,6 +346,11 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent); /* Activity: one line per message. */ table.activity td { white-space: nowrap; } table.activity td.detail { white-space: normal; color: var(--muted); } +table.activity tr.event-details > td { white-space: normal; background: var(--bg); } +.event-detail table.kv { border-collapse: collapse; margin: 4px 0; } +.event-detail table.kv th { text-align: left; font-weight: normal; color: var(--muted); padding: 2px 16px 2px 0; vertical-align: top; white-space: nowrap; + text-transform: none; letter-spacing: normal; font-size: inherit; background: none; border: 0; } +.event-detail table.kv td { padding: 2px 0; white-space: normal; word-break: break-word; background: none; border: 0; } .type-OFFER, .type-ACK { color: var(--ok); } .type-NAK, .type-DECLINE, .type-CONFLICT, .type-NO { color: var(--err); } .type-RELEASE { color: var(--muted); } @@ -355,3 +360,5 @@ input:disabled, select:disabled, textarea:disabled { background: var(--info-soft .panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; } .panel-head h2 { margin: 0; } pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; } + +.dimmed { opacity: 0.55; } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 408d42f..c191146 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -240,6 +240,33 @@ run "reservation lease time" 0 S reservation update $NA.20 --lease-time 2h run "the reserved client gets it" 0 udhcpc a 02:00:00:00:00:20 contains "BOUND ip=$NA.20 .*lease=7200" +# ---- every option of a message + +run "activity with details" 0 S activity --mac 02:00:00:00:00:20 --limit 4 -d +contains "parameter-request-list: " +contains "option 51 lease-time: 7200 s (2h)" +ID=$(S activity --mac 02:00:00:00:00:20 --limit 1 | sed -n 's/^#\([0-9]*\) .*/\1/p' | tail -1) +run "one message" 0 S activity show "$ID" +contains "DHCPACK on eth0" +contains "server-identifier: $NA.2" + +# ---- allow and deny lists + +run "deny a client" 0 S access deny iot 02:00:00:00:00:50 --comment "test" +run "a denied client gets nothing" 1 udhcpc b 02:00:00:00:00:50 +run "activity says it was denied" 0 S activity --mac 02:00:00:00:00:50 +contains "DENIED.*deny list of iot .*(test)" +run "allow-list mode" 0 S access mode iot allow-list +run "an unlisted client gets nothing" 1 udhcpc b 02:00:00:00:00:51 +run "allow by prefix" 0 S access allow iot "02:00:00:00:00:*" +run "now it gets an address" 0 udhcpc b 02:00:00:00:00:51 +contains "BOUND ip=$NB." +run "the deny list still wins" 1 udhcpc b 02:00:00:00:00:50 +run "access shows it all" 0 S access show iot +contains "allow-list: 1 allowed" +run "back to a deny-list subnet" 0 S access mode iot deny-list +run "remove the entries" 0 S access remove iot 02:00:00:00:00:50 "02:00:00:00:00:*" + # ---- backup run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup