6 Commits
Author SHA1 Message Date
abrown38andClaude Opus 5.5 f6c31858a3 Release 0.2.0-rc.3
release / release (push) Successful in 3m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:29:07 +01:00
abrown38andClaude Opus 5.5 1bc9f9f63a Let the Reserve button pick another address of the lease's subnet
The Reserve dialog fills in the lease's address but lets it be changed;
POST /leases/{ip}/reserve takes an optional ip, steward lease reserve
takes --ip. The address must be in the lease's subnet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:29:07 +01:00
abrown38andClaude Opus 5.5 c7a64773a7 Release 0.2.0-rc.2
release / release (push) Successful in 3m22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:58:57 +01:00
abrown38andClaude Opus 5.5 1407f7ab62 Log failed logins and refused tokens, address first
release / release (push) Failing after 17s
For Guard's steward preset and fail2ban: msg="login failed" ip=... user=...
and msg="token refused" ip=..., beside the audit records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:29:37 +01:00
abrown38andClaude Opus 5.5 c995ae819d Release 0.2.0-rc.1
release / release (push) Successful in 58s
0.2.0's first release candidate: allow and deny lists per subnet, and
every option of a DHCP message in words in the activity log. make test
and make e2e (144 checks with udhcpc and dhcpcd) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:27:51 +01:00
abrown38andClaude Opus 5.5 3ad2f981d4 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>
2026-10-18 20:27:51 +01:00
36 changed files with 1197 additions and 51 deletions

No files matched your search

+33
View File
@@ -4,6 +4,39 @@ All notable changes to Steward are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
[Semantic Versioning](https://semver.org/). [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- **Reserve another address from a lease.** The Reserve dialog on the
Leases page fills in the lease's address but lets you change it, to
any address of the same subnet (`steward lease reserve IP --ip ADDR`,
`"ip"` in the API). The client moves there when it next renews.
- **Failed logins in the journal.** A failed login and a refused API
token are logged as well as audited, with the address first
(`msg="login failed" ip=… user=…`, `msg="token refused" ip=…`), for
Guard's `steward` preset or a fail2ban filter to ban such addresses
(docs/operations.md).
- **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 ## [0.1.0] - 2026-10-18
The first version of Steward, a DHCPv4 server with a web UI, a REST API The first version of Steward, a DHCPv4 server with a web UI, a REST API
+4 -1
View File
@@ -111,7 +111,10 @@ Version 0.1.0 is the current release: a DHCPv4 server with subnets per
interface, reservations (by MAC address, client ID or either, with their interface, reservations (by MAC address, client ID or either, with their
own lease time), a web UI, REST API and CLI, Kea import, the web UI's own lease time), a web UI, REST API and CLI, Kea import, the web UI's
certificate through Jarl, clients' last-seen times and a choice of address certificate through Jarl, clients' last-seen times and a choice of address
order per subnet and pool. order per subnet and pool. 0.2.0 is in testing (0.2.0-rc.3):
allow and deny lists per subnet, every option of a DHCP message in
words in the activity log, failed logins logged for Guard and
fail2ban, and reserving a lease at another address of its subnet.
## Licence ## Licence
+1 -1
View File
@@ -1 +1 @@
0.1.0 0.2.0-rc.3
+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
}
+66 -10
View File
@@ -126,7 +126,7 @@ gets a NAK when it next renews, and then asks for a new address.`,
} }
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask") del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del) cmd.AddCommand(del)
var hostname, comment, by string var hostname, comment, by, ip string
reserve := &cobra.Command{ reserve := &cobra.Command{
Use: "reserve IP", Use: "reserve IP",
Short: "Make a lease's address its client's reservation", Short: "Make a lease's address its client's reservation",
@@ -136,7 +136,10 @@ then on. The host name the client sent becomes the reservation's, unless
The reservation knows the client by its MAC address and, if it sent one, The reservation knows the client by its MAC address and, if it sent one,
its client ID (option 61): either one gets it the address. --by mac or its client ID (option 61): either one gets it the address. --by mac or
--by client-id uses just one.`, --by client-id uses just one.
--ip reserves another address of the same subnet instead; the client
moves there when it next renews.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient() c, err := newClient()
@@ -153,6 +156,9 @@ its client ID (option 61): either one gets it the address. --by mac or
if by != "" { if by != "" {
body["by"] = by body["by"] = by
} }
if ip != "" {
body["ip"] = ip
}
var resp api.MutationResponse var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil { if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil {
return err return err
@@ -162,6 +168,7 @@ its client ID (option 61): either one gets it the address. --by mac or
} }
reserve.Flags().StringVar(&hostname, "hostname", "", "the reservation's host name") reserve.Flags().StringVar(&hostname, "hostname", "", "the reservation's host name")
reserve.Flags().StringVar(&comment, "comment", "", "a comment") reserve.Flags().StringVar(&comment, "comment", "", "a comment")
reserve.Flags().StringVar(&ip, "ip", "", "reserve this address of the lease's subnet instead")
reserve.Flags().StringVar(&by, "by", "", "know the client by mac, client-id or both (the default)") reserve.Flags().StringVar(&by, "by", "", "know the client by mac, client-id or both (the default)")
cmd.AddCommand(reserve) cmd.AddCommand(reserve)
return cmd return cmd
@@ -170,28 +177,37 @@ its client ID (option 61): either one gets it the address. --by mac or
func activityCmd() *cobra.Command { func activityCmd() *cobra.Command {
var mac, subnet, iface string var mac, subnet, iface string
var limit int var limit int
var follow bool var follow, details bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "activity", Use: "activity",
Short: "Show recent DHCP messages (requests and answers)", Short: "Show recent DHCP messages (requests and answers)",
Long: `Shows the latest DHCP messages Steward received and sent, newest last. 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: -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, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient() c, err := newClient()
if err != nil { if err != nil {
return err return err
} }
var last time.Time detailsQ := ""
if details {
detailsQ = "1"
}
var last uint64
tw := func(evs []api.Event) { 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 { for _, e := range evs {
if !e.Time.After(last) { if e.ID <= last {
continue continue
} }
last = e.Time last = e.ID
detail := "" detail := ""
if e.Hostname != "" { if e.Hostname != "" {
detail = " " + e.Hostname detail = " " + e.Hostname
@@ -199,12 +215,16 @@ func activityCmd() *cobra.Command {
if e.Detail != "" { if e.Detail != "" {
detail += " (" + 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 { for {
var evs []api.Event 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 return err
} }
if flagOutput == "json" || flagOutput == "yaml" { if flagOutput == "json" || flagOutput == "yaml" {
@@ -227,9 +247,45 @@ func activityCmd() *cobra.Command {
f.StringVar(&iface, "interface", "", "only this interface's") f.StringVar(&iface, "interface", "", "only this interface's")
f.IntVar(&limit, "limit", 50, "at most this many") f.IntVar(&limit, "limit", 50, "at most this many")
f.BoolVarP(&follow, "follow", "f", false, "keep watching") 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 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 { func settingsCmd() *cobra.Command {
cmd := &cobra.Command{Use: "settings", Short: "Server-wide settings and the options every subnet gets"} cmd := &cobra.Command{Use: "settings", Short: "Server-wide settings and the options every subnet gets"}
cmd.AddCommand(&cobra.Command{ 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) 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("changes", historyCmd(), importCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd()) add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
return root 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, "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, "Pools:\t%s\n", poolsText(s.Pools))
fmt.Fprintf(w, "Order:\t%s\n", orderText(s.Subnet)) 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, "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) fmt.Fprintf(w, "Reservations:\t%d (%d with a lease now)\n", s.Usage.Reservations, s.Usage.ReservationsLeased)
lt := "the default" lt := "the default"
+7 -2
View File
@@ -99,6 +99,10 @@ Creating answers 201, other changes 200.
"options": [{"option": "routers", "value": "10.0.10.1"}], "disabled": false, "comment": ""} "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 `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 pool's `:ORDER` is its own address order. `pool_order` is `listed` or
`random`; `address_order` is `sequential`, `reverse`, `random` or `random`; `address_order` is `sequential`, `reverse`, `random` or
@@ -121,8 +125,9 @@ out. `lease_time` 0 means the settings'.
| `GET /leases[?subnet=&state=&interface=&q=]` | every lease, with its state now (`active`, `expired`, `released`, `declined`), `subnet` and its reservation, and `last_seen` when the client last answered a ping (left out if it never has); `q` searches address, MAC and host name | | `GET /leases[?subnet=&state=&interface=&q=]` | every lease, with its state now (`active`, `expired`, `released`, `declined`), `subnet` and its reservation, and `last_seen` when the client last answered a ping (left out if it never has); `q` searches address, MAC and host name |
| `GET /leases/{ip}` | one | | `GET /leases/{ip}` | one |
| `DELETE /leases/{ip}` | forget it (audited) | | `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) | | `POST /leases/{ip}/reserve` | make it a reservation; optional `{"ip", "hostname", "comment", "by"}`, `ip` another address of the lease's subnet to reserve instead, `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 ### History and import
+38 -6
View File
@@ -82,30 +82,62 @@ 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 enable\|disable RES` | use a reservation, or keep it but ignore it |
| `reservation delete RES` | | | `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 ### lease
| Command | | | Command | |
|---|---| |---|---|
| `lease list [--subnet S] [--state STATE] [--interface I] [--search TEXT] [-a]` | active and held-back leases; `-a` all | | `lease list [--subnet S] [--state STATE] [--interface I] [--search TEXT] [-a]` | active and held-back leases; `-a` all |
| `lease show IP` | one lease | | `lease show IP` | one lease |
| `lease reserve IP [--hostname H] [--by mac\|client-id\|both]` | make the address its client's reservation, by its MAC address and client ID (either matches) unless `--by` says one | | `lease reserve IP [--ip ADDR] [--hostname H] [--by mac\|client-id\|both]` | make the address its client's reservation, by its MAC address and client ID (either matches) unless `--by` says one; `--ip` reserves another address of the same subnet instead |
| `lease delete IP [-y]` | free the address at once | | `lease delete IP [-y]` | free the address at once |
### activity ### 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` the latest DHCP messages, received and sent, oldest first. `--subnet`
takes an ID, name or prefix; a message without an address (a DISCOVER, takes an ID, name or prefix; a message without an address (a DISCOVER,
"no address") shows for every subnet on its interface. `-f` keeps "no address") shows for every subnet on its interface. `-f` keeps
watching, which is useful while a device tries to get an address: 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 #1042 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 #1043 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 #1044 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 #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` `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 an address that answered the ping check. A `DHCPNAK` says why it was
sent. sent.
+16
View File
@@ -69,6 +69,22 @@ clients renewing them get a NAK and ask again.
database when it starts, and an older one refuses a database a newer one database when it starts, and an older one refuses a database a newer one
has upgraded: restore a backup to go back. has upgraded: restore a backup to go back.
## Failed logins in the journal
A failed login to the web UI or the API, and a refused API token, are
written to the journal as well as the audit log, with the address first:
```
level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password"
level=WARN msg="token refused" ip=203.0.113.9 reason="wrong token secret"
```
Guard's `steward` preset, or a fail2ban filter, can ban such addresses at
the firewall (with Housecarl's ban lists). The address comes before the
user name, which a caller chooses, so a pattern anchored on
`msg="login failed" ip=` can't be fooled by a name. With `log.format:
json` they are JSON objects with the same fields in the same order.
## Checks ## Checks
`stewardd check` validates the configuration in the database against `stewardd check` validates the configuration in the database against
+40
View File
@@ -56,6 +56,7 @@ A reservation fixes a client's address:
```sh ```sh
steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas
steward lease reserve 10.0.10.57 # the address a client has now steward lease reserve 10.0.10.57 # the address a client has now
steward lease reserve 10.0.10.57 --ip 10.0.10.21 # its client, another address
``` ```
* **The client** is known by its MAC address, its client identifier * **The client** is known by its MAC address, its client identifier
@@ -201,6 +202,45 @@ renewed, and is kept across restarts.
`steward lease delete IP` frees an address at once. A client still using `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. 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 ## Authority
Steward is **authoritative** for its interfaces. A client that asks to Steward is **authoritative** for its interfaces. A client that asks to
+5
View File
@@ -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_network_address` | error | the network or broadcast address of its subnet |
| `reservation_ip_taken` | error | the address is reserved already | | `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_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_hostname` | error | not a host name (letters, digits and `-`, labels up to 63 characters) |
| `reservation_server_address` | warning, host | the server's own address | | `reservation_server_address` | warning, host | the server's own address |
+24 -6
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"time" "time"
"steward/internal/dhcp4"
"steward/internal/model" "steward/internal/model"
"steward/internal/validate" "steward/internal/validate"
"steward/internal/version" "steward/internal/version"
@@ -81,12 +82,17 @@ type SubnetInput struct {
Pools *[]string `json:"pools,omitempty"` Pools *[]string `json:"pools,omitempty"`
// PoolOrder is listed or random; AddressOrder sequential, reverse, // PoolOrder is listed or random; AddressOrder sequential, reverse,
// random or "" (the settings'). // random or "" (the settings').
PoolOrder *string `json:"pool_order,omitempty"` PoolOrder *string `json:"pool_order,omitempty"`
AddressOrder *string `json:"address_order,omitempty"` AddressOrder *string `json:"address_order,omitempty"`
LeaseTime *int `json:"lease_time,omitempty"` // Access is deny-list or allow-list; Allow and Deny replace the
Options *[]OptionInput `json:"options,omitempty"` // lists (entries: a MAC address, aa:bb:cc:* or "id HEX").
Disabled *bool `json:"disabled,omitempty"` Access *string `json:"access,omitempty"`
Comment *string `json:"comment,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). // ReservationInput creates or changes a reservation (unset fields stay).
@@ -255,6 +261,18 @@ type Event struct {
IP string `json:"ip,omitempty"` IP string `json:"ip,omitempty"`
Hostname string `json:"hostname,omitempty"` Hostname string `json:"hostname,omitempty"`
Detail string `json:"detail,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. // ImportResult reports a Kea import.
+167
View File
@@ -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
}
+36
View File
@@ -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")
}
}
+6
View File
@@ -109,6 +109,12 @@ func (c *config) reservationFor(subnetID int64, cl client) *model.Reservation {
return m["mac "+cl.mac] 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. // subnetFor finds the interface's subnet holding a.
func (c *config) subnetFor(iface string, a netip.Addr) *subnet { func (c *config) subnetFor(iface string, a netip.Addr) *subnet {
for _, s := range c.byIface[iface] { for _, s := range c.byIface[iface] {
+39 -2
View File
@@ -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()} cl := client{mac: p.MAC().String(), id: p.ClientID()}
t := p.Type() 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 { if a, ok := p.IPOption(dhcp4.OptRequestedIP); ok {
ev.IP = a.String() ev.IP = a.String()
} else if !dhcp4.IsZero(p.CIAddr) { } 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 return nil
} }
if out != 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) { if !dhcp4.IsZero(out.Packet.YIAddr) {
ev.IP = out.Packet.YIAddr.String() ev.IP = out.Packet.YIAddr.String()
} }
@@ -79,6 +86,22 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out
// ---- DISCOVER // ---- DISCOVER
func (s *Server) discover(ctx context.Context, cfg *config, iface string, subs []*subnet, p *dhcp4.Packet, cl client) *Outbound { 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) requested, _ := p.IPOption(dhcp4.OptRequestedIP)
for try := 0; try < 8; try++ { for try := 0; try < 8; try++ {
ip, sub, fresh := s.choose(cfg, iface, subs, cl, requested) 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"}) 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 // ---- REQUEST
func (s *Server) request(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client) *Outbound { 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 { if sub == nil {
return s.nak(cfg, iface, p, nil, fmt.Sprintf("%s does not belong on %s", ip, iface)) 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() now := s.now()
own := s.ownAddrs(iface) own := s.ownAddrs(iface)
s.mu.Lock() s.mu.Lock()
@@ -403,6 +436,10 @@ func (s *Server) inform(cfg *config, iface string, p *dhcp4.Packet, cl client) *
if sub == nil { if sub == nil {
return 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) sid, err := s.serverAddr(iface, sub)
if err != nil { if err != nil {
return nil return nil
+48
View File
@@ -612,3 +612,51 @@ func TestReservationLeaseTimeAndClientID(t *testing.T) {
t.Errorf("stranger: %s %d", ack.YIAddr, u32(ack)) 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)
}
}
+28
View File
@@ -15,6 +15,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"steward/internal/dhcp4"
"steward/internal/model" "steward/internal/model"
) )
@@ -324,6 +325,18 @@ type Event struct {
IP string `json:"ip,omitempty"` IP string `json:"ip,omitempty"`
Hostname string `json:"hostname,omitempty"` Hostname string `json:"hostname,omitempty"`
Detail string `json:"detail,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 { type events struct {
@@ -331,11 +344,14 @@ type events struct {
max int max int
list []Event list []Event
counts map[string]uint64 counts map[string]uint64
lastID uint64
} }
func (e *events) add(ev Event) { func (e *events) add(ev Event) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
e.lastID++
ev.ID = e.lastID
e.list = append(e.list, ev) e.list = append(e.list, ev)
if len(e.list) > e.max { if len(e.list) > e.max {
e.list = append([]Event(nil), e.list[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 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 // Counts returns how many messages of each type were seen and sent since
// the start. // the start.
func (s *Server) Counts() map[string]uint64 { func (s *Server) Counts() map[string]uint64 {
+9
View File
@@ -436,6 +436,15 @@ func Normalize(st *model.State) {
if s.PoolOrder == model.PoolsListed { if s.PoolOrder == model.PoolsListed {
s.PoolOrder = "" 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 { for _, r := range st.Reservations {
r.Options = normOptions(r.Options) r.Options = normOptions(r.Options)
+93
View File
@@ -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()
}
+6
View File
@@ -149,6 +149,12 @@ type Subnet struct {
// LeaseTime overrides the settings' lease time (0: inherit). // LeaseTime overrides the settings' lease time (0: inherit).
LeaseTime int `json:"lease_time"` LeaseTime int `json:"lease_time"`
Options []OptionValue `json:"options"` 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 // Disabled subnets keep their configuration and leases but get no
// answers. // answers.
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
+25
View File
@@ -0,0 +1,25 @@
package server
import (
"bytes"
"log/slog"
"strings"
"testing"
"steward/internal/api"
)
// TestFailedLoginLogged: a failed login and a refused token are logged
// with the address first, for Guard's and fail2ban's patterns.
func TestFailedLoginLogged(t *testing.T) {
e := newEnv(t)
var buf bytes.Buffer
e.srv.log = slog.New(slog.NewTextHandler(&buf, nil))
e.call("", "POST", "/auth/login", api.Login{Username: "x ip=8.8.8.8", Password: "wrong"}, nil)
e.call("steward_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil)
for _, want := range []string{`msg="login failed" ip=127.0.0.1 user="x ip=8.8.8.8"`, `msg="token refused" ip=127.0.0.1 reason=`} {
if !strings.Contains(buf.String(), want) {
t.Errorf("no %q in\n%s", want, buf.String())
}
}
}
+4
View File
@@ -102,6 +102,8 @@ func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session,
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent()) p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
if err != nil { if err != nil {
s.limiter.fail("ip:" + ip) s.limiter.fail("ip:" + ip)
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("token refused", "ip", ip, "reason", err.Error())
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Method: "token"}, "auth.token_failed", "", "failure", err.Error()) s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Method: "token"}, "auth.token_failed", "", "failure", err.Error())
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()} return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
} }
@@ -246,6 +248,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
if err == nil && u.Disabled && ok { if err == nil && u.Disabled && ok {
reason = "account disabled" reason = "account disabled"
} }
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("login failed", "ip", ip, "user", name, "reason", reason)
s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason) s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason)
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"} return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
} }
+20
View File
@@ -238,6 +238,26 @@ func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
if in.AddressOrder != nil { if in.AddressOrder != nil {
sn.AddressOrder = orderInput(*in.AddressOrder) 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 { if in.LeaseTime != nil {
sn.LeaseTime = *in.LeaseTime sn.LeaseTime = *in.LeaseTime
} }
+43 -3
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/netip" "net/netip"
"strconv"
"strings" "strings"
"time" "time"
@@ -113,7 +114,8 @@ func (s *Server) deleteLease(w http.ResponseWriter, r *http.Request, p *auth.Pri
return nil return nil
} }
// reserveLease makes a lease's address the client's reservation. // reserveLease makes a lease's address (or another in its subnet) the
// client's reservation.
func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
ip, err := leaseIP(r) ip, err := leaseIP(r)
if err != nil { if err != nil {
@@ -125,6 +127,8 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr
// By is how the reservation knows the client: mac, client_id or // By is how the reservation knows the client: mac, client_id or
// both (the default when the client sent a client ID). // both (the default when the client sent a client ID).
By string `json:"by,omitempty"` By string `json:"by,omitempty"`
// IP reserves another address of the lease's subnet instead.
IP string `json:"ip,omitempty"`
} }
if err := decodeOptional(r, &in); err != nil { if err := decodeOptional(r, &in); err != nil {
return err return err
@@ -160,10 +164,25 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr
} else if !validate.ValidHostname(host) { } else if !validate.ValidHostname(host) {
host = "" host = ""
} }
want := ip
if in.IP != "" {
if want, err = netip.ParseAddr(strings.TrimSpace(in.IP)); err != nil || !want.Is4() {
return badRequest("ip %q: not an IPv4 address", in.IP)
}
}
var id int64 var id int64
return s.change(w, r, p, "reserve "+ip.String()+" for "+who, func(st *model.State, newID func() int64) error { return s.change(w, r, p, "reserve "+want.String()+" for "+who, func(st *model.State, newID func() int64) error {
if want != ip {
if sub := st.SubnetFor(ip); sub == nil || !sub.Prefix.Contains(want) {
where := "the lease's subnet"
if sub != nil {
where = sub.Prefix.String()
}
return badRequest("%s isn't in %s", want, where)
}
}
id = newID() id = newID()
st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: mac, ClientID: cid, IP: ip, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment}) st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: mac, ClientID: cid, IP: want, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment})
return nil return nil
}, func(st *model.State) any { }, func(st *model.State) any {
if res := st.ReservationByID(id); res != nil { if res := st.ReservationByID(id); res != nil {
@@ -201,14 +220,35 @@ func (s *Server) listEvents(w http.ResponseWriter, r *http.Request, p *auth.Prin
} }
return true return true
}) })
details := q.Get("details") == "1"
out := make([]api.Event, len(evs)) out := make([]api.Event, len(evs))
for i, e := range evs { for i, e := range evs {
if !details {
e.Fields, e.Options = nil, nil
}
out[i] = api.Event(e) out[i] = api.Event(e)
} }
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
return nil 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 // ---- status and audit
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { 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("DELETE "+p+"/leases/{ip}", s.authed(s.deleteLease))
m.HandleFunc("POST "+p+"/leases/{ip}/reserve", s.authed(s.reserveLease)) 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", s.authed(s.listEvents))
m.HandleFunc("GET "+p+"/events/{id}", s.authed(s.getEvent))
// Status and audit // Status and audit
m.HandleFunc("GET "+p+"/status", s.authed(s.status)) m.HandleFunc("GET "+p+"/status", s.authed(s.status))
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit)) m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
+57 -2
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
@@ -247,6 +248,28 @@ func TestLeasesThroughDHCP(t *testing.T) {
if len(evs) != 4 || evs[0].Type != "DHCPACK" { if len(evs) != 4 || evs[0].Type != "DHCPACK" {
t.Errorf("events %+v", evs) 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. // 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) 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} { for ref, want := range map[string]int{"iot": 4, "10.0.20.0/24": 4, "office": 0} {
@@ -460,12 +483,16 @@ func TestReserveByClientID(t *testing.T) {
if rv.MAC != "aa:00:00:00:00:01" || rv.ClientID != "ff01" { if rv.MAC != "aa:00:00:00:00:01" || rv.ClientID != "ff01" {
t.Errorf("both: %+v", rv) t.Errorf("both: %+v", rv)
} }
if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"by": "client_id"}, &mr); code != 201 { var eb0 api.ErrorBody
if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"ip": "10.0.10.30"}, &eb0); code != 400 {
t.Errorf("reserve outside the subnet: %d %+v", code, eb0)
}
if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"ip": "10.0.20.200", "by": "client_id"}, &mr); code != 201 {
t.Fatalf("reserve by id: %d", code) t.Fatalf("reserve by id: %d", code)
} }
rv = api.Reservation{} rv = api.Reservation{}
json.Unmarshal(mr.Object, &rv) json.Unmarshal(mr.Object, &rv)
if rv.MAC != "" || rv.ClientID != "ff02" { if rv.MAC != "" || rv.ClientID != "ff02" || rv.IP.String() != "10.0.20.200" {
t.Errorf("by client id: %+v", rv) t.Errorf("by client id: %+v", rv)
} }
var eb api.ErrorBody var eb api.ErrorBody
@@ -476,3 +503,31 @@ func TestReserveByClientID(t *testing.T) {
t.Errorf("lease time: %d", code) 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)
}
}
+27 -6
View File
@@ -25,16 +25,16 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
if st.Settings.Options == nil { if st.Settings.Options == nil {
st.Settings.Options = []model.OptionValue{} 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 { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
sn := &model.Subnet{} sn := &model.Subnet{}
var prefix, pools, opts string var prefix, pools, opts, allow, deny string
var disabled int 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 return nil, err
} }
sn.Prefix, _ = netip.ParsePrefix(prefix) 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 { if err := json.Unmarshal([]byte(opts), &sn.Options); err != nil {
return nil, fmt.Errorf("subnet %d options: %w", sn.ID, err) 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) st.Subnets = append(st.Subnets, sn)
} }
if err := rows.Err(); err != nil { 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 { if err := json.Unmarshal(c.After, &sn); err != nil {
return err 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, 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`, pool_order = excluded.pool_order, address_order = excluded.address_order, lease_time = excluded.lease_time, options = excluded.options,
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) 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 return err
case model.KindReservation: case model.KindReservation:
if c.After == nil { if c.After == nil {
@@ -176,6 +190,13 @@ func nonNil(p []model.Pool) []model.Pool {
return p return p
} }
func nonNilMatches(m []model.ClientMatch) []model.ClientMatch {
if m == nil {
return []model.ClientMatch{}
}
return m
}
func nonNilOpts(o []model.OptionValue) []model.OptionValue { func nonNilOpts(o []model.OptionValue) []model.OptionValue {
if o == nil { if o == nil {
return []model.OptionValue{} return []model.OptionValue{}
+6
View File
@@ -210,6 +210,12 @@ ALTER TABLE subnets ADD COLUMN address_order TEXT NOT NULL DEFAULT '';
// 6: a reservation's own lease time. // 6: a reservation's own lease time.
` `
ALTER TABLE reservations ADD COLUMN lease_time INTEGER NOT NULL DEFAULT 0; 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 '[]';
`, `,
} }
+35
View File
@@ -164,6 +164,7 @@ func (c *checker) subnets(st *model.State, env *Env) {
c.leaseTime(s.LeaseTime, obj) c.leaseTime(s.LeaseTime, obj)
} }
c.pools(s, obj) c.pools(s, obj)
c.access(st, s, obj)
c.options(s.Options, obj) c.options(s.Options, obj)
c.routers(s, st.Settings.Options, obj) c.routers(s, st.Settings.Options, obj)
if env != nil && !s.Disabled && s.Interface != "" { 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. // 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) { func (c *checker) routers(s *model.Subnet, global []model.OptionValue, obj string) {
val := "" val := ""
+43 -5
View File
@@ -1,24 +1,52 @@
// Activity: the latest DHCP messages, refreshed every few seconds. // Activity: the latest DHCP messages, refreshed every few seconds.
import { h, mount, input, select, debounce } from "../ui.js"; import { h, mount, input, select, debounce, toast, modal, errorBox } from "../ui.js";
import { get } from "../api.js"; import { get, change, can } from "../api.js";
import { onLeave } from "../app.js"; import { onLeave } from "../app.js";
import { subnetLabel } from "../widgets.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) { export async function render(el, params, query) {
const [ifaces, subnets] = await Promise.all([get("/interfaces"), get("/subnets")]); 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 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 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 iface = select([["", "all interfaces"], ...ifaces.filter((i) => i.subnets.length).map((i) => [i.name, i.name])], query.get("interface") || "");
const out = h("div"); 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; let live = true;
const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause"); const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause");
const draw = async () => { const draw = async () => {
const evs = await get("/events", { mac: mac.value.trim(), subnet: subnetSel.value, interface: iface.value, limit: 300 }); 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" }, 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) => { h("tbody", null, evs.map((e) => {
const t = e.type.replace(/^DHCP/, ""); 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 small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
h("td", { class: "mono" }, e.interface), h("td", { class: "mono" }, e.interface),
h("td", null, h("strong", { class: "type-" + t.split(" ")[0] }, t)), 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")] : ""), 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", { class: "mono" }, e.ip || ""),
h("td", null, e.hostname || ""), 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." : "."))); })))) : 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)); mac.addEventListener("input", debounce(draw, 300));
subnetSel.addEventListener("change", draw); subnetSel.addEventListener("change", draw);
iface.addEventListener("change", draw); iface.addEventListener("change", draw);
+6 -3
View File
@@ -36,18 +36,21 @@ function stateBadge(st) {
} }
async function reserve(l, done) { async function reserve(l, done) {
const ip = input({ value: l.ip, class: "mono" });
const host = input({ value: l.hostname || "", placeholder: "host name (optional)" }); const host = input({ value: l.hostname || "", placeholder: "host name (optional)" });
const by = select([["both", "MAC address or client ID (either one)"], ["mac", "MAC address only"], ["client_id", "client ID only"]], "both"); const by = select([["both", "MAC address or client ID (either one)"], ["mac", "MAC address only"], ["client_id", "client ID only"]], "both");
const err = h("div"); const err = h("div");
await modal("Reserve " + l.ip, h("div", null, await modal("Reserve " + l.ip, h("div", null,
h("p", null, (l.mac || "The client") + " keeps " + l.ip + " from now on."), h("p", null, (l.mac || "The client") + " keeps this address from now on."),
h("div", { class: "form-grid" }, h("label", null, "Host name"), host, h("div", { class: "form-grid" }, h("label", null, "Address"), ip, h("label"),
h("div", { class: "help" }, "Another address of the same subnet moves the client there when it next renews."),
h("label", null, "Host name"), host,
l.client_id && l.mac ? [h("label", null, "Know it by"), by, h("label"), l.client_id && l.mac ? [h("label", null, "Know it by"), by, h("label"),
h("div", { class: "help" }, "It sent the client ID ", h("span", { class: "mono" }, l.client_id), ". With either one, it gets the address whichever system it boots.")] : null), err), [ h("div", { class: "help" }, "It sent the client ID ", h("span", { class: "mono" }, l.client_id), ". With either one, it gets the address whichever system it boots.")] : null), err), [
{ label: "Cancel", value: null }, { label: "Cancel", value: null },
{ label: "Reserve", kind: "primary", onclick: async () => { { label: "Reserve", kind: "primary", onclick: async () => {
try { try {
await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim(), by: l.client_id && l.mac ? by.value : undefined }); await change("POST", "/leases/" + l.ip + "/reserve", { ip: ip.value.trim(), hostname: host.value.trim(), by: l.client_id && l.mac ? by.value : undefined });
done(); done();
return true; return true;
} catch (e) { } catch (e) {
+54 -3
View File
@@ -1,12 +1,12 @@
// A subnet's page: its leases, its reservations and its settings. // 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 { get, change, can } from "../api.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText, orderText } from "../widgets.js"; import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText, orderText } from "../widgets.js";
import { leasesTable } from "./leases.js"; import { leasesTable } from "./leases.js";
import { reservationsTable, reservationDialog } from "./reservations.js"; import { reservationsTable, reservationDialog } from "./reservations.js";
import { rerender } from "../app.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) { export async function render(el, params) {
const id = params.id; 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 ", 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", s.pools.length ? h("span", { class: "mono" }, s.pools.map(poolText).join(", ")) : "none",
orderText(s) ? " · " + orderText(s) : "", 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)), h("div", { class: "subnet-usage" }, usageBar(s.usage)),
can("config", true) ? h("div", { class: "actions" }, h("button", { 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", 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); body);
if (tab === "leases") await leasesTab(body, s); if (tab === "leases") await leasesTab(body, s);
else if (tab === "reservations") await reservationsTab(body, s); else if (tab === "reservations") await reservationsTab(body, s);
else if (tab === "access") accessTab(body, s);
else await settingsTab(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) { 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; 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 { try {
+7
View File
@@ -346,6 +346,11 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
/* Activity: one line per message. */ /* Activity: one line per message. */
table.activity td { white-space: nowrap; } table.activity td { white-space: nowrap; }
table.activity td.detail { white-space: normal; color: var(--muted); } 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-OFFER, .type-ACK { color: var(--ok); }
.type-NAK, .type-DECLINE, .type-CONFLICT, .type-NO { color: var(--err); } .type-NAK, .type-DECLINE, .type-CONFLICT, .type-NO { color: var(--err); }
.type-RELEASE { color: var(--muted); } .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 { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.panel-head h2 { margin: 0; } .panel-head h2 { margin: 0; }
pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; } pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
.dimmed { opacity: 0.55; }
+27
View File
@@ -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 run "the reserved client gets it" 0 udhcpc a 02:00:00:00:00:20
contains "BOUND ip=$NA.20 .*lease=7200" 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 # ---- backup
run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup