8 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
abrown38andClaude Opus 5.5 b3c8329ae8 Release 0.1.0
release / release (push) Successful in 57s
Steward's first release: a DHCPv4 server with subnets per 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 certificate
through Jarl, clients' last-seen times, the activity log by subnet,
hidden interfaces and the address order per subnet and pool. make test
and make e2e (124 checks with udhcpc and dhcpcd) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:26:30 +01:00
abrown38andClaude Opus 5.5 4fd72998ec Reservation lease time; reservations by MAC address or client ID
- A reservation can have its own lease time (--lease-time, the
  reservation dialog), over the subnet's. Schema migration 6.
- A reservation with a MAC address and a client ID matches either one
  (a dual-boot machine sends two client IDs from one MAC); before, it
  was found by MAC and then refused. A lease of the reserved address by
  the client's other identity counts as its own.
- The Leases page shows a client's client ID; reserving a lease uses
  both, or one with --by / the dialog's choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:26:24 +01:00
37 changed files with 1430 additions and 96 deletions

No files matched your search

+39
View File
@@ -6,6 +6,39 @@ All notable changes to Steward are documented here. The format follows
## [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
The first version of Steward, a DHCPv4 server with a web UI, a REST API
and a CLI, built the way Jarl is.
@@ -124,6 +157,12 @@ and a CLI, built the way Jarl is.
used in the order listed, or one at random (`--pool-order random`),
weighted by its never-used addresses. Pools now keep the order they are
given in. Kea's `allocator: random` is imported.
- **A reservation's own lease time** (`--lease-time 7d`, the reservation
dialog), over the subnet's.
- **Reservations by MAC address and client ID together**: either one gets
the client its address (a dual-boot machine sends two client IDs from
one MAC). The Leases page shows the client ID a client sent, and
reserving a lease uses both (`steward lease reserve --by` for one).
- **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`,
Rotate in the web UI): a new secret for a token, with an optional grace
period for the old one, as in Jarl.
+8 -4
View File
@@ -107,10 +107,14 @@ sudo systemctl disable --now kea-dhcp4-server
## Status
Steward 0.1.0 is in testing (0.1.0-rc.1): a DHCPv4 server with subnets per
interface, reservations, 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 order per subnet and pool.
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
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
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
+1 -1
View File
@@ -1 +1 @@
0.1.0-rc.1
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
}
+74 -10
View File
@@ -126,13 +126,20 @@ gets a NAK when it next renews, and then asks for a new address.`,
}
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del)
var hostname, comment string
var hostname, comment, by, ip string
reserve := &cobra.Command{
Use: "reserve IP",
Short: "Make a lease's address its client's reservation",
Long: `Makes the address a client has now its reservation: it keeps it from
then on. The host name the client sent becomes the reservation's, unless
--hostname gives another.`,
--hostname gives another.
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
--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),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
@@ -146,6 +153,12 @@ then on. The host name the client sent becomes the reservation's, unless
if comment != "" {
body["comment"] = comment
}
if by != "" {
body["by"] = by
}
if ip != "" {
body["ip"] = ip
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil {
return err
@@ -155,6 +168,8 @@ then on. The host name the client sent becomes the reservation's, unless
}
reserve.Flags().StringVar(&hostname, "hostname", "", "the reservation's host name")
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)")
cmd.AddCommand(reserve)
return cmd
}
@@ -162,28 +177,37 @@ then on. The host name the client sent becomes the reservation's, unless
func activityCmd() *cobra.Command {
var mac, subnet, iface string
var limit int
var follow bool
var follow, details bool
cmd := &cobra.Command{
Use: "activity",
Short: "Show recent DHCP messages (requests and answers)",
Long: `Shows the latest DHCP messages Steward received and sent, newest last.
-f keeps watching. Handy to see why a device doesn't get an address:
steward activity --mac aa:bb:cc:dd:ee:01 -f`,
steward activity --mac aa:bb:cc:dd:ee:01 -f
--details (-d) shows every option of each message in words: what the
client asked for (its parameter request list), its client ID, and what
Steward sent back. 'steward activity show N' shows one message (N is the
number at the start of its line).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var last time.Time
detailsQ := ""
if details {
detailsQ = "1"
}
var last uint64
tw := func(evs []api.Event) {
sort.Slice(evs, func(i, j int) bool { return evs[i].Time.Before(evs[j].Time) })
sort.Slice(evs, func(i, j int) bool { return evs[i].ID < evs[j].ID })
for _, e := range evs {
if !e.Time.After(last) {
if e.ID <= last {
continue
}
last = e.Time
last = e.ID
detail := ""
if e.Hostname != "" {
detail = " " + e.Hostname
@@ -191,12 +215,16 @@ func activityCmd() *cobra.Command {
if e.Detail != "" {
detail += " (" + e.Detail + ")"
}
fmt.Printf("%s %-8s %-13s %-17s %-15s%s\n", e.Time.Local().Format("15:04:05.000"), e.Interface, e.Type, orDash(e.MAC), orDash(e.IP), detail)
fmt.Printf("%-6s %s %-8s %-13s %-17s %-15s%s\n", fmt.Sprintf("#%d", e.ID), e.Time.Local().Format("15:04:05.000"), e.Interface, e.Type, orDash(e.MAC), orDash(e.IP), detail)
if details {
printEventDetails(os.Stdout, e, " ")
fmt.Println()
}
}
}
for {
var evs []api.Event
if err := c.Get(ctx(), "/events", q("mac", mac, "subnet", subnet, "interface", iface, "limit", fmt.Sprint(limit)), &evs); err != nil {
if err := c.Get(ctx(), "/events", q("mac", mac, "subnet", subnet, "interface", iface, "limit", fmt.Sprint(limit), "details", detailsQ), &evs); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
@@ -219,9 +247,45 @@ func activityCmd() *cobra.Command {
f.StringVar(&iface, "interface", "", "only this interface's")
f.IntVar(&limit, "limit", 50, "at most this many")
f.BoolVarP(&follow, "follow", "f", false, "keep watching")
f.BoolVarP(&details, "details", "d", false, "show each message's fields and options")
cmd.AddCommand(&cobra.Command{
Use: "show N", Short: "Show one DHCP message with all its options", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var e api.Event
if err := c.Get(ctx(), "/events/"+strings.TrimPrefix(args[0], "#"), nil, &e); err != nil {
return err
}
return output(e, func(w io.Writer) {
dir := map[string]string{"in": "received from the client", "out": "sent by Steward"}[e.Direction]
fmt.Fprintf(w, "#%d %s on %s at %s", e.ID, e.Type, e.Interface, e.Time.Local().Format("2006-01-02 15:04:05.000"))
if dir != "" {
fmt.Fprintf(w, ", %s", dir)
}
fmt.Fprintln(w)
if e.Detail != "" {
fmt.Fprintf(w, "%s\n", e.Detail)
}
printEventDetails(w, e, " ")
})
},
})
return cmd
}
// printEventDetails writes a message's fields and options, one per line.
func printEventDetails(w io.Writer, e api.Event, indent string) {
for _, f := range e.Fields {
fmt.Fprintf(w, "%s%s: %s\n", indent, f.Name, f.Value)
}
for _, o := range e.Options {
fmt.Fprintf(w, "%soption %d %s: %s\n", indent, o.Code, o.Name, o.Value)
}
}
func settingsCmd() *cobra.Command {
cmd := &cobra.Command{Use: "settings", Short: "Server-wide settings and the options every subnet gets"}
cmd.AddCommand(&cobra.Command{
+1 -1
View File
@@ -173,7 +173,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
root.AddCommand(c)
}
}
add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd())
add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), accessCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd())
add("changes", historyCmd(), importCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
return root
+33 -8
View File
@@ -93,6 +93,7 @@ func subnetCmd() *cobra.Command {
fmt.Fprintf(w, "Prefix:\t%s (mask %s)\n", s.Prefix, model.Mask(s.Prefix))
fmt.Fprintf(w, "Pools:\t%s\n", poolsText(s.Pools))
fmt.Fprintf(w, "Order:\t%s\n", orderText(s.Subnet))
fmt.Fprintf(w, "Access:\t%s\n", accessText(s.Subnet))
fmt.Fprintf(w, "Leased:\t%s, %d declined\n", usageText(s.Usage), s.Usage.Declined)
fmt.Fprintf(w, "Reservations:\t%d (%d with a lease now)\n", s.Usage.Reservations, s.Usage.ReservationsLeased)
lt := "the default"
@@ -385,7 +386,7 @@ func reservationCmd() *cobra.Command {
for _, r := range rs {
who := r.MAC
if r.ClientID != "" {
who = "id " + r.ClientID
who = strings.TrimPrefix(r.MAC+" or id "+r.ClientID, " or ")
}
lease := "-"
if r.Lease != nil {
@@ -427,6 +428,11 @@ func reservationCmd() *cobra.Command {
}
fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname))
fmt.Fprintln(w, "Options:\t"+optionsText(r.Options))
lt := "the subnet's"
if r.LeaseTime > 0 {
lt = fmtSeconds(r.LeaseTime)
}
fmt.Fprintf(w, "Lease time:\t%s\n", lt)
if r.Lease != nil {
fmt.Fprintf(w, "Lease:\tuntil %s, on %s\n", r.Lease.End.Local().Format("2006-01-02 15:04"), r.Lease.Interface)
if r.Lease.LastSeen != nil {
@@ -467,17 +473,18 @@ func reservationCmd() *cobra.Command {
})
}
var hostname, clientID, comment, ip, mac string
var hostname, clientID, comment, ip, mac, leaseTime string
var opts, unsetOpts []string
add := &cobra.Command{
Use: "add IP MAC",
Short: "Reserve an address for a client",
Long: `Reserves an address for a client, known by its MAC address (or by its
client identifier: give "-" for the MAC and --client-id). The address picks
the subnet. An address inside a pool is fine: it is never handed out to
anyone else.
Long: `Reserves an address for a client, known by its MAC address, its client
identifier (option 61: give "-" for the MAC and --client-id), or both:
then either one gets it the address, as for a machine that boots two
systems. The address picks the subnet. An address inside a pool is fine:
it is never handed out to anyone else.
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 --lease-time 7d`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
@@ -497,6 +504,13 @@ anyone else.
if comment != "" {
in.Comment = &comment
}
if leaseTime != "" {
n, err := parseSeconds(leaseTime)
if err != nil {
return err
}
in.LeaseTime = &n
}
if len(opts) > 0 {
o, err := optionsArg(opts)
if err != nil {
@@ -513,7 +527,8 @@ anyone else.
}
f := add.Flags()
f.StringVar(&hostname, "hostname", "", "the host name to send it")
f.StringVar(&clientID, "client-id", "", "match the client identifier (hex) instead of the MAC address")
f.StringVar(&clientID, "client-id", "", "the client identifier (hex): with a MAC address, either one matches")
f.StringVar(&leaseTime, "lease-time", "", "this client's lease time (12h, 7d; default: the subnet's)")
f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE for this client only")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
@@ -547,6 +562,15 @@ anyone else.
if fl.Changed("comment") {
in.Comment = &comment
}
if fl.Changed("lease-time") {
n := 0
if leaseTime != "default" {
if n, err = parseSeconds(leaseTime); err != nil {
return err
}
}
in.LeaseTime = &n
}
if len(opts) > 0 || len(unsetOpts) > 0 {
set, err := optionsArg(opts)
if err != nil {
@@ -570,6 +594,7 @@ anyone else.
f.StringVar(&mac, "mac", "", "a new MAC address (\"\" for none)")
f.StringVar(&clientID, "client-id", "", "a client identifier (\"\" for none)")
f.StringVar(&hostname, "hostname", "", "a new host name")
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 7d, or \"default\": the subnet's)")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
f.StringVar(&comment, "comment", "", "a comment")
+8 -3
View File
@@ -99,6 +99,10 @@ Creating answers 201, other changes 200.
"options": [{"option": "routers", "value": "10.0.10.1"}], "disabled": false, "comment": ""}
```
`access` is `deny-list` (the default, left out) or `allow-list`; `allow`
and `deny` replace the lists, each `[{"match": "aa:bb:cc:*", "comment":
"Sonos"}]` with a MAC address, a prefix or `"id HEX"`
([subnets.md](subnets.md#access-allow-and-deny-lists)).
`pools` and `options` replace the lists; the pools keep their order. A
pool's `:ORDER` is its own address order. `pool_order` is `listed` or
`random`; `address_order` is `sequential`, `reverse`, `random` or
@@ -111,7 +115,7 @@ out. `lease_time` 0 means the settings'.
| | |
|---|---|
| `GET /reservations[?subnet=REF]` | each with `subnet_id`, `subnet` and its current `lease` |
| `POST /reservations` | `{"ip", "mac", "client_id", "hostname", "options", "disabled", "comment"}`; `ip` and `mac` or `client_id` needed |
| `POST /reservations` | `{"ip", "mac", "client_id", "hostname", "options", "lease_time", "disabled", "comment"}`; `ip` and `mac` or `client_id` needed (with both, either matches); `lease_time` 0 is the subnet's |
| `GET`, `PATCH`, `DELETE /reservations/{ref}` | by ID, address, or MAC address (when it has one reservation) |
### Leases and activity
@@ -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/{ip}` | one |
| `DELETE /leases/{ip}` | forget it (audited) |
| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment"}` |
| `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 |
| `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=&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
+40 -8
View File
@@ -77,35 +77,67 @@ one. An option Steward doesn't know: `--option 224:string=hello`.
|---|---|
| `reservation list [--subnet SUBNET]` | reservations, whether each client is online, and when it last answered a ping |
| `reservation show RES` | RES is its ID, address or MAC address |
| `reservation add IP MAC [--hostname H] [--client-id HEX] [--option NAME=VALUE]... [--comment C]` | `-` for MAC with `--client-id` |
| `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--option]... [--unset-option]... [--comment]` | |
| `reservation add IP MAC [--hostname H] [--client-id HEX] [--lease-time 7d] [--option NAME=VALUE]... [--comment C]` | `-` for MAC with `--client-id`; with both, either matches |
| `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--lease-time 7d\|default] [--option]... [--unset-option]... [--comment]` | |
| `reservation enable\|disable RES` | use a reservation, or keep it but ignore it |
| `reservation delete RES` | |
### access
| Command | |
|---|---|
| `access show SUBNET` | the subnet's mode and its allow and deny lists |
| `access mode SUBNET deny-list\|allow-list` | answer every client not denied, or only the allowed (and reserved) |
| `access allow SUBNET ENTRY... [--comment C]` | add to the allow list (`aa:bb:cc:dd:ee:ff`, `aa:bb:cc:*` or `id HEX`); an entry it has gets the new comment |
| `access deny SUBNET ENTRY... [--comment C]` | add to the deny list |
| `access remove SUBNET ENTRY...` | take off both lists |
See [subnets.md](subnets.md#access-allow-and-deny-lists).
### lease
| Command | |
|---|---|
| `lease list [--subnet S] [--state STATE] [--interface I] [--search TEXT] [-a]` | active and held-back leases; `-a` all |
| `lease show IP` | one lease |
| `lease reserve IP [--hostname H]` | make the address its client's reservation |
| `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 |
### activity
`steward activity [--mac MAC] [--subnet SUBNET] [--interface I] [--limit N] [-f]`:
`steward activity [--mac MAC] [--subnet SUBNET] [--interface I] [--limit N] [-d] [-f]`:
the latest DHCP messages, received and sent, oldest first. `--subnet`
takes an ID, name or prefix; a message without an address (a DISCOVER,
"no address") shows for every subnet on its interface. `-f` keeps
watching, which is useful while a device tries to get an address:
```
11:02:13.120 eth0 DHCPDISCOVER 3c:22:fb:11:22:33 - adams-macbook
11:02:13.622 eth0 DHCPOFFER 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
11:02:13.630 eth0 DHCPREQUEST 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
11:02:13.631 eth0 DHCPACK 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
#1042 11:02:13.120 eth0 DHCPDISCOVER 3c:22:fb:11:22:33 - adams-macbook
#1043 11:02:13.622 eth0 DHCPOFFER 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
#1044 11:02:13.630 eth0 DHCPREQUEST 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
#1045 11:02:13.631 eth0 DHCPACK 3c:22:fb:11:22:33 10.0.10.150 adams-macbook
```
Each line starts with the message's number. `-d` (`--details`) adds every
field and option of each message in words, and `steward activity show N`
shows one: what the client asked for (its parameter request list, by
name), its client identifier and host name, and what Steward sent back
(lease time, routers, DNS servers...):
```
$ steward activity show 1042
#1042 DHCPDISCOVER on eth0 at 2026-10-18 11:02:13.120, received from the client
transaction ID: 0x3a1c99f0
client hardware address: 3c:22:fb:11:22:33
flags: broadcast (the client wants replies broadcast)
option 53 message-type: DHCPDISCOVER
option 61 client-identifier: 01:3c:22:fb:11:22:33 (type 1: the MAC address 3c:22:fb:11:22:33)
option 55 parameter-request-list: subnet-mask (1), routers (3), domain-name-servers (6), domain-name (15), v4-captive-portal (114)
option 12 host-name: adams-macbook
```
In the web UI, ▸ on an Activity row opens the same.
`NO ADDRESS` means the pools on that interface are full, and `CONFLICT`
an address that answered the ping check. A `DHCPNAK` says why it was
sent.
+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
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
`stewardd check` validates the configuration in the database against
+52 -5
View File
@@ -56,17 +56,23 @@ A reservation fixes a client's address:
```sh
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 --ip 10.0.10.21 # its client, another address
```
* **The client** is known by its MAC address, or by its client identifier
(option 61) when the reservation has one (`--client-id`; give `-` as the
MAC). Some devices change their identifier but not their MAC address,
and others the other way round. When unsure, use the MAC address.
* **The client** is known by its MAC address, its client identifier
(option 61, `--client-id`; give `-` as the MAC for an identifier alone),
or both. With both, **either one** gets the client its address: a
machine that boots two systems sends different identifiers from one MAC
address, and a device with a randomised MAC may keep its identifier.
The Leases page and `steward lease show` show the identifier a client
sent; `steward lease reserve IP` uses both when it sent one (`--by mac`
or `--by client-id` for just one).
* **The address picks the subnet**: the subnet whose network holds it. It
may be inside a pool or outside one; either way, nobody else gets it.
* **The host name** is sent to the client (option 12).
* **Options** set on the reservation are for that client only, and win
over the subnet's and the settings'.
* **A lease time** of its own (`--lease-time 7d`) wins over the subnet's.
* **A device on two VLANs** can have a reservation in each subnet. Within
one subnet, a client has at most one.
@@ -108,7 +114,9 @@ a list, it gets every option set for it.
## Lease times
The settings have the default (12 hours, `steward settings set
--lease-time 1d`); a subnet can have its own (`--lease-time 1h`). Clients
--lease-time 1d`); a subnet can have its own (`--lease-time 1h`), and a
reservation its own over that (`steward reservation update nas
--lease-time 7d`). Clients
renew halfway through. Short times let changes reach clients sooner;
long ones keep clients working longer if Steward is down.
@@ -194,6 +202,45 @@ renewed, and is kept across restarts.
`steward lease delete IP` frees an address at once. A client still using
it gets a NAK when it next renews, then asks for a new address.
## Access: allow and deny lists
A subnet decides who it answers:
* **deny-list** (the default): every client except those on its deny
list;
* **allow-list**: only clients on its allow list or with a reservation in
it, and not on its deny list.
The deny list counts in both modes, and wins over a reservation; so an
allow-list subnet can allow a maker's devices by prefix and still deny one
of them. Switching the mode keeps both lists.
Entries are a MAC address (`aa:bb:cc:dd:ee:ff`), a prefix for one maker's
devices (`aa:bb:cc:*`, 1 to 5 bytes) or a client identifier
(`id 01aabbccddeeff`), each with an optional comment:
```sh
steward access mode iot allow-list
steward access allow iot aa:bb:cc:* --comment "Sonos speakers"
steward access deny office 3c:22:fb:11:22:33 --comment "old laptop"
steward access remove office 3c:22:fb:11:22:33
steward access show iot
```
In the web UI it is a subnet's **Access** tab; a DENIED line in Activity
has an **Allow** button that puts that client on the subnet's allow list.
A refused client gets **no answer**: its DISCOVER shows as `DENIED` in the
activity log, with the reason. A client refused after it got its lease
gets a **NAK** when it next renews, so it stops using the address then
(within half its lease time).
This is a control, not security: anyone can copy an allowed MAC address.
Phones and laptops also use a **private MAC address** per network by
default (iOS, Android, Windows), different from the one on their label;
on an allow-list subnet, turn that off for the network on the device, or
allow the private address it shows (Activity lists it).
## Authority
Steward is **authoritative** for its interfaces. A client that asks to
+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_ip_taken` | error | the address is reserved already |
| `reservation_client_taken` | error | the client has a reservation in this subnet already (one per subnet; a device on two VLANs may have one in each) |
| `reservation_denied` | warning | a reservation's client is on its subnet's deny list, so it gets no address |
| `access_mode` | error | a subnet's access is not `deny-list` or `allow-list` |
| `access_entry` | error | an allow or deny list entry that isn't a MAC address, a prefix (`aa:bb:cc:*`) or `id HEX` |
| `access_entry_duplicate` | error | an entry is on the same list twice |
| `allow_list_empty` | warning | an allow-list subnet with no allow list entries and no reservations: nobody gets an address |
| `reservation_hostname` | error | not a host name (letters, digits and `-`, labels up to 63 characters) |
| `reservation_server_address` | warning, host | the server's own address |
+20
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
"steward/internal/validate"
"steward/internal/version"
@@ -83,6 +84,11 @@ type SubnetInput struct {
// random or "" (the settings').
PoolOrder *string `json:"pool_order,omitempty"`
AddressOrder *string `json:"address_order,omitempty"`
// Access is deny-list or allow-list; Allow and Deny replace the
// lists (entries: a MAC address, aa:bb:cc:* or "id HEX").
Access *string `json:"access,omitempty"`
Allow *[]model.ClientMatch `json:"allow,omitempty"`
Deny *[]model.ClientMatch `json:"deny,omitempty"`
LeaseTime *int `json:"lease_time,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
@@ -96,6 +102,8 @@ type ReservationInput struct {
IP *string `json:"ip,omitempty"`
Hostname *string `json:"hostname,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
// LeaseTime in seconds; 0 for the subnet's.
LeaseTime *int `json:"lease_time,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
}
@@ -253,6 +261,18 @@ type Event struct {
IP string `json:"ip,omitempty"`
Hostname string `json:"hostname,omitempty"`
Detail string `json:"detail,omitempty"`
// ID numbers the events since stewardd started.
ID uint64 `json:"id"`
// Direction is "in" for a client's message, "out" for Steward's
// answer, "" for an event of Steward's own (CONFLICT, NO ADDRESS).
Direction string `json:"direction,omitempty"`
ClientID string `json:"client_id,omitempty"`
// SubnetID is the subnet that refused a client (DENIED).
SubnetID int64 `json:"subnet_id,omitempty"`
// Fields and Options are the message, in words (GET /events/{id}, or
// ?details=1).
Fields []dhcp4.Field `json:"fields,omitempty"`
Options []dhcp4.Field `json:"options,omitempty"`
}
// ImportResult reports a Kea import.
+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")
}
}
+10 -6
View File
@@ -109,6 +109,12 @@ func (c *config) reservationFor(subnetID int64, cl client) *model.Reservation {
return m["mac "+cl.mac]
}
// admits says whether the subnet answers the client (its allow and deny
// lists), and if not why.
func (c *config) admits(sub *subnet, cl client) (bool, string) {
return sub.Admits(cl.mac, cl.id, c.reservationFor(sub.ID, cl) != nil)
}
// subnetFor finds the interface's subnet holding a.
func (c *config) subnetFor(iface string, a netip.Addr) *subnet {
for _, s := range c.byIface[iface] {
@@ -125,13 +131,11 @@ type client struct {
id string // client identifier (option 61) in hex, or ""
}
// matches reports whether a reservation is for the client: by client
// identifier when the reservation has one, else by MAC address.
// matches reports whether the reservation is the client's: by its client
// identifier or its MAC address, either one when the reservation has both
// (a dual-boot machine sends different client IDs from one MAC address).
func (cl client) matches(r *model.Reservation) bool {
if r.ClientID != "" {
return r.ClientID == cl.id
}
return r.MAC == cl.mac
return r.ClientID != "" && r.ClientID == cl.id || r.MAC != "" && r.MAC == cl.mac
}
func (cl client) String() string {
+60 -11
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()}
t := p.Type()
ev := Event{Time: s.now(), Interface: iface, Type: t.String(), MAC: cl.mac, Hostname: p.HostName()}
ev := Event{Time: s.now(), Interface: iface, Type: t.String(), MAC: cl.mac, Hostname: p.HostName(), Direction: "in", ClientID: cl.id}
ev.Fields, ev.Options = p.Describe()
if a, ok := p.IPOption(dhcp4.OptRequestedIP); ok {
ev.IP = a.String()
} else if !dhcp4.IsZero(p.CIAddr) {
@@ -64,7 +65,13 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out
return nil
}
if out != nil {
ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName()}
ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName(), Direction: "out", ClientID: cl.id}
ev.Fields, ev.Options = out.Packet.Describe()
if out.Dst.IsValid() {
ev.Fields = append(ev.Fields, dhcp4.Field{Name: "sent to", Value: out.Dst.String() + " (unicast)"})
} else {
ev.Fields = append(ev.Fields, dhcp4.Field{Name: "sent to", Value: "255.255.255.255 (broadcast on " + iface + ")"})
}
if !dhcp4.IsZero(out.Packet.YIAddr) {
ev.IP = out.Packet.YIAddr.String()
}
@@ -79,6 +86,22 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out
// ---- DISCOVER
func (s *Server) discover(ctx context.Context, cfg *config, iface string, subs []*subnet, p *dhcp4.Packet, cl client) *Outbound {
// Only the subnets whose access lists let the client in.
var allowed []*subnet
var refusedBy *subnet
why := ""
for _, sub := range subs {
if ok, reason := cfg.admits(sub, cl); ok {
allowed = append(allowed, sub)
} else {
refusedBy, why = sub, reason
}
}
if len(allowed) == 0 {
s.denied(iface, refusedBy, cl, why)
return nil
}
subs = allowed
requested, _ := p.IPOption(dhcp4.OptRequestedIP)
for try := 0; try < 8; try++ {
ip, sub, fresh := s.choose(cfg, iface, subs, cl, requested)
@@ -190,8 +213,9 @@ func (s *Server) usable(cfg *config, own map[netip.Addr]bool, sub *subnet, ip ne
if !sub.Prefix.Contains(ip) || ip == sub.network || ip == sub.broadcast || own[ip] {
return false
}
if r := cfg.resByIP[ip]; r != nil {
if !cl.matches(r) {
res := cfg.resByIP[ip]
if res != nil {
if !cl.matches(res) {
return false
}
} else {
@@ -211,7 +235,10 @@ func (s *Server) usable(cfg *config, own map[netip.Addr]bool, sub *subnet, ip ne
return false
}
}
if l := s.leases[ip]; l != nil && l.HeldAt(now) && (l.State == model.LeaseDeclined || !l.Owns(cl.mac, cl.id)) {
// A lease of the reserved address by the reservation's client under
// its other identity (the other OS of a dual-boot machine) is its own.
if l := s.leases[ip]; l != nil && l.HeldAt(now) && (l.State == model.LeaseDeclined ||
!l.Owns(cl.mac, cl.id) && !(res != nil && (client{mac: l.MAC, id: l.ClientID}).matches(res))) {
return false
}
if o, ok := s.offers[ip]; ok && now.Before(o.expires) && o.cl != cl {
@@ -234,6 +261,11 @@ func (s *Server) conflict(ctx context.Context, cfg *config, iface string, sub *s
s.events.add(Event{Time: now, Interface: iface, Type: "CONFLICT", IP: ip.String(), Detail: "answered a ping; held back"})
}
// denied records a client a subnet's access lists refused.
func (s *Server) denied(iface string, sub *subnet, cl client, why string) {
s.events.add(Event{Time: s.now(), Interface: iface, Type: "DENIED", MAC: cl.mac, ClientID: cl.id, SubnetID: sub.ID, Detail: "no answer: the client is " + why})
}
// ---- REQUEST
func (s *Server) request(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client) *Outbound {
@@ -273,6 +305,11 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa
if sub == nil {
return s.nak(cfg, iface, p, nil, fmt.Sprintf("%s does not belong on %s", ip, iface))
}
// A client refused since it got its lease: a NAK makes it start over
// at once (its DISCOVER then gets no answer).
if ok, why := cfg.admits(sub, cl); !ok {
return s.nak(cfg, iface, p, sub, "this client is "+why)
}
now := s.now()
own := s.ownAddrs(iface)
s.mu.Lock()
@@ -296,8 +333,12 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa
host = l.Hostname
}
}
res := cfg.reservationFor(sub.ID, cl)
if res != nil && res.IP != ip {
res = nil
}
l := &model.Lease{IP: ip, SubnetID: sub.ID, MAC: cl.mac, ClientID: cl.id, Hostname: host, State: model.LeaseActive,
Start: start, End: now.Add(sub.leaseTime), Interface: iface, LastSeen: seen}
Start: start, End: now.Add(leaseTime(sub, res)), Interface: iface, LastSeen: seen}
err := s.putLease(ctx, l)
delete(s.offers, ip)
s.mu.Unlock()
@@ -312,10 +353,6 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa
s.warnOnce("sid "+iface, 10*time.Minute, "can't answer DHCP: "+err.Error(), "interface", iface)
return nil
}
res := cfg.reservationFor(sub.ID, cl)
if res != nil && res.IP != ip {
res = nil
}
r := p.Reply(dhcp4.Ack)
r.YIAddr = ip
r.CIAddr = p.CIAddr
@@ -399,6 +436,10 @@ func (s *Server) inform(cfg *config, iface string, p *dhcp4.Packet, cl client) *
if sub == nil {
return nil
}
if ok, why := cfg.admits(sub, cl); !ok {
s.denied(iface, sub, cl, why)
return nil
}
sid, err := s.serverAddr(iface, sub)
if err != nil {
return nil
@@ -413,6 +454,14 @@ func (s *Server) inform(cfg *config, iface string, p *dhcp4.Packet, cl client) *
return &Outbound{Packet: r, Dst: p.CIAddr}
}
// leaseTime is a lease's length: the reservation's, else the subnet's.
func leaseTime(sub *subnet, res *model.Reservation) time.Duration {
if res != nil && res.LeaseTime > 0 {
return time.Duration(res.LeaseTime) * time.Second
}
return sub.leaseTime
}
// ---- options
// Options always sent when configured, even when the client doesn't ask.
@@ -425,7 +474,7 @@ var essential = []byte{dhcp4.OptSubnetMask, dhcp4.OptRouters, dhcp4.OptDNSServer
func (s *Server) addOptions(r, req *dhcp4.Packet, sub *subnet, res *model.Reservation, sid netip.Addr, lease bool) {
r.Set(dhcp4.OptServerID, dhcp4.IP(sid))
if lease {
lt := uint32(sub.leaseTime / time.Second)
lt := uint32(leaseTime(sub, res) / time.Second)
r.Set(dhcp4.OptLeaseTime, dhcp4.U32(lt))
r.Set(dhcp4.OptRenewalTime, dhcp4.U32(lt/2))
r.Set(dhcp4.OptRebindingTime, dhcp4.U32(lt*7/8))
+82 -2
View File
@@ -2,6 +2,7 @@ package dhcpd
import (
"context"
"encoding/binary"
"errors"
"net"
"net/netip"
@@ -115,9 +116,9 @@ func (h *harness) discover(iface string, m net.HardwareAddr, opts ...dhcp4.Optio
}
// lease runs DISCOVER and REQUEST and returns the ACK.
func (h *harness) lease(iface string, m net.HardwareAddr) *dhcp4.Packet {
func (h *harness) lease(iface string, m net.HardwareAddr, opts ...dhcp4.Option) *dhcp4.Packet {
h.t.Helper()
off := h.discover(iface, m)
off := h.discover(iface, m, opts...)
if off == nil || off.Packet.Type() != dhcp4.Offer {
h.t.Fatalf("no offer for %s on %s", m, iface)
}
@@ -126,6 +127,9 @@ func (h *harness) lease(iface string, m net.HardwareAddr) *dhcp4.Packet {
req.Set(dhcp4.OptServerID, dhcp4.IP(sid))
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr))
req.Set(dhcp4.OptHostName, []byte("client"+m.String()[15:]))
for _, o := range opts {
req.Set(o.Code, o.Data)
}
ack := h.send(iface, req)
if ack == nil || ack.Packet.Type() != dhcp4.Ack {
h.t.Fatalf("no ACK for %s: %+v", m, ack)
@@ -580,3 +584,79 @@ func TestAddressOrder(t *testing.T) {
t.Errorf("a full subnet offered %s", out.Packet.YIAddr)
}
}
func TestReservationLeaseTimeAndClientID(t *testing.T) {
h := newHarness(t, func(st *model.State) {
st.Reservations[0].LeaseTime = 600
st.Reservations[0].ClientID = "ff0102"
})
u32 := func(p *dhcp4.Packet) uint32 { return binary.BigEndian.Uint32(p.Get(dhcp4.OptLeaseTime)) }
id := func(hex ...byte) dhcp4.Option { return dhcp4.Option{Code: dhcp4.OptClientID, Data: hex} }
// By its client ID, from any MAC address, with its own lease time.
ack := h.lease("eth0", mac(0x50), id(0xff, 1, 2))
if ack.YIAddr.String() != "10.0.10.20" || u32(ack) != 600 {
t.Errorf("by client id: %s %d", ack.YIAddr, u32(ack))
}
if l := h.s.Lease(ack.YIAddr); !l.End.Equal(h.clock.Add(10 * time.Minute)) {
t.Errorf("lease end %v", l.End)
}
// By its MAC address with another client ID (the machine's other OS):
// the same address, though the first identity's lease is still on.
ack = h.lease("eth0", mac(0x99), id(1, 0xaa, 0, 0, 0, 0, 0x99))
if ack.YIAddr.String() != "10.0.10.20" {
t.Errorf("by MAC: %s", ack.YIAddr)
}
// Anyone else gets a pool address, with the subnet's lease time.
ack = h.lease("eth0", mac(0x51), id(0xff, 9, 9))
if ack.YIAddr.String() == "10.0.10.20" || u32(ack) != 43200 {
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"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
)
@@ -324,6 +325,18 @@ type Event struct {
IP string `json:"ip,omitempty"`
Hostname string `json:"hostname,omitempty"`
Detail string `json:"detail,omitempty"`
// ID numbers the events since stewardd started.
ID uint64 `json:"id"`
// Direction is "in" for a client's message, "out" for Steward's
// answer, "" for an event of Steward's own (CONFLICT, NO ADDRESS).
Direction string `json:"direction,omitempty"`
ClientID string `json:"client_id,omitempty"`
// SubnetID is the subnet that refused a client (DENIED).
SubnetID int64 `json:"subnet_id,omitempty"`
// Fields and Options are the message, in words (GET /events/{id}, or
// ?details=1).
Fields []dhcp4.Field `json:"fields,omitempty"`
Options []dhcp4.Field `json:"options,omitempty"`
}
type events struct {
@@ -331,11 +344,14 @@ type events struct {
max int
list []Event
counts map[string]uint64
lastID uint64
}
func (e *events) add(ev Event) {
e.mu.Lock()
defer e.mu.Unlock()
e.lastID++
ev.ID = e.lastID
e.list = append(e.list, ev)
if len(e.list) > e.max {
e.list = append([]Event(nil), e.list[len(e.list)-e.max:]...)
@@ -362,6 +378,18 @@ func (s *Server) Events(limit int, keep func(Event) bool) []Event {
return out
}
// Event returns one event, with its details.
func (s *Server) Event(id uint64) (Event, bool) {
s.events.mu.Lock()
defer s.events.mu.Unlock()
for i := len(s.events.list) - 1; i >= 0; i-- {
if s.events.list[i].ID == id {
return s.events.list[i], true
}
}
return Event{}, false
}
// Counts returns how many messages of each type were seen and sent since
// the start.
func (s *Server) Counts() map[string]uint64 {
+9
View File
@@ -436,6 +436,15 @@ func Normalize(st *model.State) {
if s.PoolOrder == model.PoolsListed {
s.PoolOrder = ""
}
if s.Access == model.AccessDenyList {
s.Access = ""
}
if len(s.Allow) == 0 {
s.Allow = nil
}
if len(s.Deny) == 0 {
s.Deny = nil
}
}
for _, r := range st.Reservations {
r.Options = normOptions(r.Options)
+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()
}
+10
View File
@@ -149,6 +149,12 @@ type Subnet struct {
// LeaseTime overrides the settings' lease time (0: inherit).
LeaseTime int `json:"lease_time"`
Options []OptionValue `json:"options"`
// Access is who the subnet answers: everyone not on Deny ("", a
// deny-list subnet) or, as an allow-list subnet, only clients on
// Allow or with a reservation, and not on Deny (access.go).
Access string `json:"access,omitempty"`
Allow []ClientMatch `json:"allow,omitempty"`
Deny []ClientMatch `json:"deny,omitempty"`
// Disabled subnets keep their configuration and leases but get no
// answers.
Disabled bool `json:"disabled"`
@@ -262,12 +268,16 @@ func Mask(p netip.Prefix) netip.Addr {
// set. The subnet is the one whose prefix holds the address.
type Reservation struct {
ID int64 `json:"id"`
// The client is known by its MAC address, its client identifier
// (option 61), or either when both are set.
MAC string `json:"mac,omitempty"`
ClientID string `json:"client_id,omitempty"`
IP netip.Addr `json:"ip"`
// Hostname is sent to the client (option 12).
Hostname string `json:"hostname"`
Options []OptionValue `json:"options"`
// LeaseTime overrides the subnet's lease time (0: inherit).
LeaseTime int `json:"lease_time,omitempty"`
// Disabled reservations are kept but ignored: the client is treated
// like any other, and an address inside a pool may go to others.
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())
if err != nil {
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())
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 {
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)
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
}
+23
View File
@@ -238,6 +238,26 @@ func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
if in.AddressOrder != nil {
sn.AddressOrder = orderInput(*in.AddressOrder)
}
if in.Access != nil {
sn.Access = strings.ToLower(strings.TrimSpace(*in.Access))
}
for _, l := range []struct {
in *[]model.ClientMatch
out *[]model.ClientMatch
}{{in.Allow, &sn.Allow}, {in.Deny, &sn.Deny}} {
if l.in == nil {
continue
}
list := []model.ClientMatch{}
for _, m := range *l.in {
norm, err := model.ParseClientMatch(m.Match)
if err != nil {
return badRequest("%v", err)
}
list = append(list, model.ClientMatch{Match: norm, Comment: strings.TrimSpace(m.Comment)})
}
*l.out = list
}
if in.LeaseTime != nil {
sn.LeaseTime = *in.LeaseTime
}
@@ -440,6 +460,9 @@ func applyReservationInput(res *model.Reservation, in *api.ReservationInput) err
}
res.Options = opts
}
if in.LeaseTime != nil {
res.LeaseTime = *in.LeaseTime
}
if in.Disabled != nil {
res.Disabled = *in.Disabled
}
+68 -4
View File
@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
@@ -113,7 +114,8 @@ func (s *Server) deleteLease(w http.ResponseWriter, r *http.Request, p *auth.Pri
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 {
ip, err := leaseIP(r)
if err != nil {
@@ -122,24 +124,65 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr
var in struct {
Hostname *string `json:"hostname,omitempty"`
Comment string `json:"comment,omitempty"`
// By is how the reservation knows the client: mac, client_id or
// both (the default when the client sent a client ID).
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 {
return err
}
l := s.dhcp.Lease(ip)
if l == nil || l.MAC == "" {
if l == nil || l.MAC == "" && l.ClientID == "" {
return notFound("no lease for %s", ip)
}
mac, cid := l.MAC, l.ClientID
switch strings.ReplaceAll(in.By, "-", "_") {
case "", "both":
case "mac":
cid = ""
case "client_id":
mac = ""
default:
return badRequest("by %q: use mac, client_id or both", in.By)
}
if mac == "" && cid == "" {
what := "client ID (option 61); reserve it by mac"
if in.By == "mac" {
what = "MAC address; reserve it by client_id"
}
return badRequest("the client of %s sent no %s", ip, what)
}
who := mac
if cid != "" {
who = strings.TrimSpace(mac + " client id " + cid)
}
host := l.Hostname
if in.Hostname != nil {
host = *in.Hostname
} else if !validate.ValidHostname(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
return s.change(w, r, p, "reserve "+ip.String()+" for "+l.MAC, 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()
st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: l.MAC, 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
}, func(st *model.State) any {
if res := st.ReservationByID(id); res != nil {
@@ -177,14 +220,35 @@ func (s *Server) listEvents(w http.ResponseWriter, r *http.Request, p *auth.Prin
}
return true
})
details := q.Get("details") == "1"
out := make([]api.Event, len(evs))
for i, e := range evs {
if !details {
e.Fields, e.Options = nil, nil
}
out[i] = api.Event(e)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// getEvent: GET /events/{id}, one message with its fields and options.
func (s *Server) getEvent(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLeases, false); err != nil {
return err
}
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
return badRequest("%q is not an event number", r.PathValue("id"))
}
e, ok := s.dhcp.Event(id)
if !ok {
return notFound("no event %d: only the last 1000 are kept, and none from before stewardd started", id)
}
writeJSON(w, http.StatusOK, api.Event(e))
return nil
}
// ---- status and audit
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
+1
View File
@@ -60,6 +60,7 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/leases/{ip}", s.authed(s.deleteLease))
m.HandleFunc("POST "+p+"/leases/{ip}/reserve", s.authed(s.reserveLease))
m.HandleFunc("GET "+p+"/events", s.authed(s.listEvents))
m.HandleFunc("GET "+p+"/events/{id}", s.authed(s.getEvent))
// Status and audit
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
+97
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
@@ -247,6 +248,28 @@ func TestLeasesThroughDHCP(t *testing.T) {
if len(evs) != 4 || evs[0].Type != "DHCPACK" {
t.Errorf("events %+v", evs)
}
// Details: on their own, or with ?details=1.
if len(evs[0].Options) != 0 {
t.Errorf("list with options: %+v", evs[0])
}
var ev api.Event
if code := e.call(e.token, "GET", "/events/"+fmt.Sprint(evs[0].ID), nil, &ev); code != 200 || ev.Direction != "out" || len(ev.Options) == 0 {
t.Errorf("event: %d %+v", code, ev)
}
found := false
for _, o := range ev.Options {
found = found || o.Name == "lease-time" && o.Value == "43200 s (12h)"
}
if !found {
t.Errorf("no lease time in %+v", ev.Options)
}
e.call(e.token, "GET", "/events?details=1&mac="+mac.String(), nil, &evs)
if len(evs[3].Options) == 0 || evs[3].Direction != "in" {
t.Errorf("details=1: %+v", evs[3])
}
if code := e.call(e.token, "GET", "/events/99999", nil, nil); code != 404 {
t.Errorf("missing event: %d", code)
}
// By subnet: its addresses, and DISCOVERs on its interface.
e.call(e.token, "POST", "/subnets", api.SubnetInput{Name: ptr("office"), Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0")}, nil)
for ref, want := range map[string]int{"iot": 4, "10.0.20.0/24": 4, "office": 0} {
@@ -434,3 +457,77 @@ func TestAddressOrderAPI(t *testing.T) {
t.Errorf("bad pool order: %d", code)
}
}
func TestReserveByClientID(t *testing.T) {
e := newEnv(t)
e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), Pools: &[]string{"10.0.20.100-10.0.20.110"}}, nil)
get := func(id byte) netip.Addr {
m := net.HardwareAddr{0xaa, 0, 0, 0, 0, id}
disc := dhcp4.NewRequest(dhcp4.Discover, m, 1)
disc.Set(dhcp4.OptClientID, []byte{0xff, id})
off := e.dhcp.Handle(context.Background(), "eth0.20", disc)
req := dhcp4.NewRequest(dhcp4.Request, m, 2)
req.Set(dhcp4.OptClientID, []byte{0xff, id})
req.Set(dhcp4.OptServerID, dhcp4.IP(netip.MustParseAddr("10.0.20.1")))
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr))
e.dhcp.Handle(context.Background(), "eth0.20", req)
return off.Packet.YIAddr
}
a, b := get(1), get(2)
var mr api.MutationResponse
if code := e.call(e.token, "POST", "/leases/"+a.String()+"/reserve", map[string]any{}, &mr); code != 201 {
t.Fatalf("reserve: %d", code)
}
var rv api.Reservation
json.Unmarshal(mr.Object, &rv)
if rv.MAC != "aa:00:00:00:00:01" || rv.ClientID != "ff01" {
t.Errorf("both: %+v", rv)
}
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)
}
rv = api.Reservation{}
json.Unmarshal(mr.Object, &rv)
if rv.MAC != "" || rv.ClientID != "ff02" || rv.IP.String() != "10.0.20.200" {
t.Errorf("by client id: %+v", rv)
}
var eb api.ErrorBody
if code := e.call(e.token, "PATCH", "/reservations/"+itoa(rv.ID), api.ReservationInput{LeaseTime: ptr(10)}, &eb); code != 422 || eb.Error.Problems[0].Code != "lease_time_range" {
t.Errorf("short lease time: %d %+v", code, eb)
}
if code := e.call(e.token, "PATCH", "/reservations/"+itoa(rv.ID), api.ReservationInput{LeaseTime: ptr(7 * 86400)}, nil); code != 200 || e.eng.State().ReservationByID(rv.ID).LeaseTime != 7*86400 {
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)
}
}
+32 -11
View File
@@ -25,16 +25,16 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
if st.Settings.Options == nil {
st.Settings.Options = []model.OptionValue{}
}
rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, disabled, comment FROM subnets ORDER BY id`)
rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, access, allow_list, deny_list, disabled, comment FROM subnets ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
sn := &model.Subnet{}
var prefix, pools, opts string
var prefix, pools, opts, allow, deny string
var disabled int
if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.PoolOrder, &sn.AddressOrder, &sn.LeaseTime, &opts, &disabled, &sn.Comment); err != nil {
if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.PoolOrder, &sn.AddressOrder, &sn.LeaseTime, &opts, &sn.Access, &allow, &deny, &disabled, &sn.Comment); err != nil {
return nil, err
}
sn.Prefix, _ = netip.ParsePrefix(prefix)
@@ -45,12 +45,24 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
if err := json.Unmarshal([]byte(opts), &sn.Options); err != nil {
return nil, fmt.Errorf("subnet %d options: %w", sn.ID, err)
}
if err := json.Unmarshal([]byte(allow), &sn.Allow); err != nil {
return nil, fmt.Errorf("subnet %d allow list: %w", sn.ID, err)
}
if err := json.Unmarshal([]byte(deny), &sn.Deny); err != nil {
return nil, fmt.Errorf("subnet %d deny list: %w", sn.ID, err)
}
if len(sn.Allow) == 0 {
sn.Allow = nil
}
if len(sn.Deny) == 0 {
sn.Deny = nil
}
st.Subnets = append(st.Subnets, sn)
}
if err := rows.Err(); err != nil {
return nil, err
}
rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, disabled, comment FROM reservations ORDER BY id`)
rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, lease_time, disabled, comment FROM reservations ORDER BY id`)
if err != nil {
return nil, err
}
@@ -59,7 +71,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
r := &model.Reservation{}
var ip, opts string
var disabled int
if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &disabled, &r.Comment); err != nil {
if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &r.LeaseTime, &disabled, &r.Comment); err != nil {
return nil, err
}
r.IP, _ = netip.ParseAddr(ip)
@@ -146,10 +158,12 @@ func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error {
if err := json.Unmarshal(c.After, &sn); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?,?,?)
_, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, access, allow_list, deny_list, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name, interface = excluded.interface, prefix = excluded.prefix, pools = excluded.pools,
pool_order = excluded.pool_order, address_order = excluded.address_order, lease_time = excluded.lease_time, options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`,
sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.PoolOrder, sn.AddressOrder, sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment)
pool_order = excluded.pool_order, address_order = excluded.address_order, lease_time = excluded.lease_time, options = excluded.options,
access = excluded.access, allow_list = excluded.allow_list, deny_list = excluded.deny_list, disabled = excluded.disabled, comment = excluded.comment`,
sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.PoolOrder, sn.AddressOrder, sn.LeaseTime, jsonText(nonNilOpts(sn.Options)),
sn.Access, jsonText(nonNilMatches(sn.Allow)), jsonText(nonNilMatches(sn.Deny)), boolInt(sn.Disabled), sn.Comment)
return err
case model.KindReservation:
if c.After == nil {
@@ -160,10 +174,10 @@ func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error {
if err := json.Unmarshal(c.After, &r); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?)
_, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, lease_time, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET mac = excluded.mac, client_id = excluded.client_id, ip = excluded.ip, hostname = excluded.hostname,
options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`,
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), boolInt(r.Disabled), r.Comment)
options = excluded.options, lease_time = excluded.lease_time, disabled = excluded.disabled, comment = excluded.comment`,
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), r.LeaseTime, boolInt(r.Disabled), r.Comment)
return err
}
return fmt.Errorf("unknown kind %q", c.Kind)
@@ -176,6 +190,13 @@ func nonNil(p []model.Pool) []model.Pool {
return p
}
func nonNilMatches(m []model.ClientMatch) []model.ClientMatch {
if m == nil {
return []model.ClientMatch{}
}
return m
}
func nonNilOpts(o []model.OptionValue) []model.OptionValue {
if o == nil {
return []model.OptionValue{}
+10
View File
@@ -206,6 +206,16 @@ ALTER TABLE leases ADD COLUMN last_seen TEXT;
`
ALTER TABLE subnets ADD COLUMN pool_order TEXT NOT NULL DEFAULT '';
ALTER TABLE subnets ADD COLUMN address_order TEXT NOT NULL DEFAULT '';
`,
// 6: a reservation's own lease time.
`
ALTER TABLE reservations ADD COLUMN lease_time INTEGER NOT NULL DEFAULT 0;
`,
// 7: who a subnet answers: its access mode and allow and deny lists.
`
ALTER TABLE subnets ADD COLUMN access TEXT NOT NULL DEFAULT '';
ALTER TABLE subnets ADD COLUMN allow_list TEXT NOT NULL DEFAULT '[]';
ALTER TABLE subnets ADD COLUMN deny_list TEXT NOT NULL DEFAULT '[]';
`,
}
+46 -4
View File
@@ -164,6 +164,7 @@ func (c *checker) subnets(st *model.State, env *Env) {
c.leaseTime(s.LeaseTime, obj)
}
c.pools(s, obj)
c.access(st, s, obj)
c.options(s.Options, obj)
c.routers(s, st.Settings.Options, obj)
if env != nil && !s.Disabled && s.Interface != "" {
@@ -206,6 +207,40 @@ func (c *checker) pools(s *model.Subnet, obj string) {
}
}
// access checks a subnet's access mode and lists.
func (c *checker) access(st *model.State, s *model.Subnet, obj string) {
if s.Access != "" && s.Access != model.AccessDenyList && s.Access != model.AccessAllowList {
c.add(Error, "access_mode", obj, "the access %q is not deny-list or allow-list", s.Access)
}
for _, list := range []struct {
name string
entries []model.ClientMatch
}{{"allow", s.Allow}, {"deny", s.Deny}} {
seen := map[string]bool{}
for _, m := range list.entries {
if norm, err := model.ParseClientMatch(m.Match); err != nil || norm != m.Match {
c.add(Error, "access_entry", obj, "the %s list entry %q is not a MAC address (aa:bb:cc:dd:ee:ff), a prefix (aa:bb:cc:*) or a client ID (id 01aabbccddeeff)", list.name, m.Match)
} else if seen[m.Match] {
c.add(Error, "access_entry_duplicate", obj, "%s is on the %s list twice", m.Match, list.name)
}
seen[m.Match] = true
}
}
reserved := 0
for _, r := range st.Reservations {
if r.Disabled || !s.Prefix.Contains(r.IP) {
continue
}
reserved++
if ok, _ := s.Admits(r.MAC, r.ClientID, true); !ok {
c.add(Warning, "reservation_denied", obj, "the client of reservation %s is on the deny list: it gets no address", r.Label())
}
}
if s.Access == model.AccessAllowList && len(s.Allow) == 0 && reserved == 0 && !s.Disabled {
c.add(Warning, "allow_list_empty", obj, "the subnet answers only clients on its allow list or with a reservation, and it has neither: nobody gets an address")
}
}
// routers warns about a gateway outside the subnet: clients can't reach it.
func (c *checker) routers(s *model.Subnet, global []model.OptionValue, obj string) {
val := ""
@@ -294,15 +329,22 @@ func (c *checker) reservations(st *model.State, env *Env) {
c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label())
}
byIP[r.IP] = r
k := key{s.ID, "mac " + r.MAC}
if r.ClientID != "" {
k.id = "id " + r.ClientID
// Either of a client's MAC address and client ID finds its
// reservation, so neither may be another's.
for _, k := range []key{{s.ID, "mac " + r.MAC}, {s.ID, "id " + r.ClientID}} {
if k.id == "mac " || k.id == "id " {
continue
}
if o := byClient[k]; o != nil {
if o := byClient[k]; o != nil && o != r {
c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label())
break
}
byClient[k] = r
}
}
if r.LeaseTime != 0 {
c.leaseTime(r.LeaseTime, obj)
}
if r.Hostname != "" && !validHostname(r.Hostname) {
c.add(Error, "reservation_hostname", obj, "%q isn't a valid host name (letters, digits and '-', labels up to 63 characters, separated by dots)", r.Hostname)
}
+43 -5
View File
@@ -1,24 +1,52 @@
// Activity: the latest DHCP messages, refreshed every few seconds.
import { h, mount, input, select, debounce } from "../ui.js";
import { get } from "../api.js";
import { h, mount, input, select, debounce, toast, modal, errorBox } from "../ui.js";
import { get, change, can } from "../api.js";
import { onLeave } from "../app.js";
import { subnetLabel } from "../widgets.js";
// allowClient puts a refused client on its subnet's allow list.
async function allowClient(e) {
try {
const s = await get("/subnets/" + e.subnet_id);
if ((s.allow || []).some((m) => m.match === e.mac)) { toast(e.mac + " is already allowed on " + subnetLabel(s)); return; }
const allow = [...(s.allow || []), { match: e.mac, comment: "" }];
await change("PATCH", "/subnets/" + e.subnet_id, { allow });
toast(e.mac + " is allowed on " + subnetLabel(s) + "; it gets an address when it next asks");
} catch (err) { modal("Not allowed", errorBox(err)); }
}
// details shows a message's header fields and options in words.
function details(e) {
if (!e) return h("div", { class: "muted small" }, "Loading…");
if (e.error) return h("div", { class: "msg err" }, e.error);
const who = e.direction === "in" ? "Received from the client" : "Sent by Steward";
return h("div", { class: "event-detail" },
h("div", { class: "muted small" }, who),
h("table", { class: "kv" }, h("tbody", null,
e.fields.map((f) => h("tr", null, h("th", null, f.name), h("td", { class: "mono" }, f.value))),
(e.options || []).map((o) => h("tr", null, h("th", null, o.name, h("span", { class: "muted small" }, " (" + o.code + ")")),
h("td", { class: "mono" }, o.value))))));
}
export async function render(el, params, query) {
const [ifaces, subnets] = await Promise.all([get("/interfaces"), get("/subnets")]);
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
const mac = input({ placeholder: "MAC address", value: query.get("mac") || "", class: "mono" });
const iface = select([["", "all interfaces"], ...ifaces.filter((i) => i.subnets.length).map((i) => [i.name, i.name])], query.get("interface") || "");
const out = h("div");
// Messages opened to show their options, kept across refreshes.
const open = new Map(); // id -> the event with its details, or null while loading
let live = true;
const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause");
const draw = async () => {
const evs = await get("/events", { mac: mac.value.trim(), subnet: subnetSel.value, interface: iface.value, limit: 300 });
mount(out, evs.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
h("thead", null, h("tr", null, ["Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))),
h("thead", null, h("tr", null, ["", "Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))),
h("tbody", null, evs.map((e) => {
const t = e.type.replace(/^DHCP/, "");
return h("tr", null,
const row = h("tr", null,
h("td", null, e.direction ? h("button", { class: "small", title: open.has(e.id) ? "Hide the options" : "Show every option of this message",
onclick: () => toggle(e.id) }, open.has(e.id) ? "▾" : "▸") : ""),
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
h("td", { class: "mono" }, e.interface),
h("td", null, h("strong", { class: "type-" + t.split(" ")[0] }, t)),
@@ -27,9 +55,19 @@ export async function render(el, params, query) {
mac.value.trim() ? null : h("button", { class: "small reveal", title: "Show only this client's messages", onclick: () => { mac.value = e.mac; draw(); } }, "Only this")] : ""),
h("td", { class: "mono" }, e.ip || ""),
h("td", null, e.hostname || ""),
h("td", { class: "detail" }, e.detail || ""));
h("td", { class: "detail" }, e.detail || "",
e.type === "DENIED" && e.subnet_id && e.mac && (e.detail || "").includes("allow list") && can("config", true)
? [" ", h("button", { class: "small", title: "Put this client on the subnet's allow list", onclick: () => allowClient(e) }, "Allow")] : null));
return open.has(e.id) ? [row, h("tr", { class: "event-details" }, h("td"), h("td", { colspan: 7 }, details(open.get(e.id))))] : row;
})))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || subnetSel.value || iface.value ? " that match." : ".")));
};
const toggle = async (id) => {
if (open.has(id)) { open.delete(id); draw(); return; }
open.set(id, null);
draw();
try { open.set(id, await get("/events/" + id)); } catch (e) { open.set(id, { error: e.message }); }
draw();
};
mac.addEventListener("input", debounce(draw, 300));
subnetSel.addEventListener("change", draw);
iface.addEventListener("change", draw);
+12 -5
View File
@@ -16,7 +16,8 @@ export function leasesTable(leases, done, opts = {}) {
const write = can("leases", true), config = can("config", true);
return table([
{ label: "Address", render: (l) => h("span", { class: "mono" }, l.ip) },
{ label: "MAC address", render: (l) => h("span", { class: "mono" }, l.mac || "–") },
{ label: "Client", render: (l) => h("span", { class: "mono" }, l.mac || "–",
l.client_id ? h("div", { class: "muted small", title: "Client identifier (option 61)" }, "id " + (l.client_id.length > 20 ? l.client_id.slice(0, 18) + "…" : l.client_id)) : null) },
{ label: "Host name", render: leaseHost },
{ label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] },
{ label: "Ends", render: (l) => time(l.end) },
@@ -24,7 +25,7 @@ export function leasesTable(leases, done, opts = {}) {
opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" },
{ label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) },
{ label: "", tdClass: "actions-cell", render: (l) => [
config && !l.reservation && l.mac && l.state !== "declined" ? h("button", { class: "small reveal", title: "Give this client this address from now on", onclick: () => reserve(l, done) }, "Reserve") : null, " ",
config && !l.reservation && (l.mac || l.client_id) && l.state !== "declined" ? h("button", { class: "small reveal", title: "Give this client this address from now on", onclick: () => reserve(l, done) }, "Reserve") : null, " ",
write ? h("button", { class: "small reveal", title: "Forget this lease: the address is free at once", onclick: () => del(l, done) }, "Delete") : null,
] },
].filter(Boolean), leases, { empty: opts.empty || "No leases." });
@@ -35,15 +36,21 @@ function stateBadge(st) {
}
async function reserve(l, done) {
const ip = input({ value: l.ip, class: "mono" });
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 err = h("div");
await modal("Reserve " + l.ip, h("div", null,
h("p", null, l.mac + " keeps " + l.ip + " from now on."),
h("div", { class: "form-grid" }, h("label", null, "Host name"), host), err), [
h("p", null, (l.mac || "The client") + " keeps this address from now on."),
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"),
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: "Reserve", kind: "primary", onclick: async () => {
try {
await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim() });
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();
return true;
} catch (e) {
@@ -1,7 +1,7 @@
// Reservations: fixed addresses for clients.
import { h, mount, table, time, input, modal, errorBox, field, confirmDialog, select, debounce, badge, checkbox } from "../ui.js";
import { get, change, can } from "../api.js";
import { optionsEditor, subnetLabel } from "../widgets.js";
import { optionsEditor, subnetLabel, fmtSeconds, parseSeconds } from "../widgets.js";
import { onLeave } from "../app.js";
// reservationsTable lists reservations with Edit and Delete.
@@ -9,7 +9,7 @@ export function reservationsTable(rs, done, opts = {}) {
const write = can("config", true);
return table([
{ label: "Address", render: (r) => [h("span", { class: "mono" }, r.ip), r.disabled ? [" ", badge("disabled", "off")] : null] },
{ label: "Client", render: (r) => h("span", { class: "mono" }, r.client_id ? "id " + r.client_id : r.mac) },
{ label: "Client", render: (r) => h("span", { class: "mono" }, r.mac || "", r.mac && r.client_id ? h("span", { class: "muted small" }, " or ") : "", r.client_id ? "id " + r.client_id : "") },
{ label: "Host name", render: (r) => r.hostname || h("span", { class: "muted" }, "–") },
opts.subnet ? null : { label: "Subnet", render: (r) => r.subnet_id ? h("a", { href: "#/subnets/" + r.subnet_id }, r.subnet) : "–" },
{ label: "Online", render: (r) => r.lease
@@ -51,7 +51,8 @@ export async function reservationDialog(r, done, preset = {}) {
const inherited = [...settings.options.filter((o) => !(sub && sub.options.some((x) => x.code === o.code))), ...(sub ? sub.options : [])];
const ip = input({ value: r ? r.ip : preset.ip || "", placeholder: "10.0.10.20", class: "mono" });
const mac = input({ value: r ? r.mac || "" : preset.mac || "", placeholder: "aa:bb:cc:dd:ee:ff", class: "mono" });
const cid = input({ value: r ? r.client_id || "" : "", placeholder: "only for clients known by a client identifier", class: "mono" });
const cid = input({ value: r ? r.client_id || "" : preset.client_id || "", placeholder: "optional: option 61, in hex", class: "mono" });
const lease = input({ value: r && r.lease_time ? fmtSeconds(r.lease_time) : "", placeholder: "the subnet's" });
const host = input({ value: r ? r.hostname : preset.hostname || "", placeholder: "nas" });
const opts = await optionsEditor(r ? r.options : [], inherited);
const comment = input({ value: r ? r.comment : "" });
@@ -60,7 +61,8 @@ export async function reservationDialog(r, done, preset = {}) {
const body = h("div", null, h("div", { class: "form-grid" },
field("Address", ip, "Its subnet is the one whose network holds it. Inside a pool is fine: nobody else gets it."),
field("MAC address", mac),
field("Client identifier", cid, "Optional: match the client's identifier (option 61, hex) instead of its MAC address."),
field("Client identifier", cid, "Optional: the client's identifier (option 61, hex). With a MAC address too, either one gets the client this address (a machine that boots two systems sends different identifiers)."),
field("Lease time", lease, "This client's lease time (12h, 7d); empty for the subnet's."),
field("Host name", host, "Sent to the client."),
field("Options", opts, "Only for this client; they win over the subnet's."),
field("Comment", comment),
@@ -69,7 +71,9 @@ export async function reservationDialog(r, done, preset = {}) {
{ label: "Cancel", value: null },
{ label: r ? "Save" : "Add reservation", kind: "primary", onclick: async () => {
mount(err);
const v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), comment: comment.value };
const lt = parseSeconds(lease.value);
if (lt === null) { mount(err, h("div", { class: "msg err" }, "Lease time: write it as 12h, 7d or 30m")); return false; }
const v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), lease_time: lt, comment: comment.value };
if (r) v.disabled = disabled.input.checked;
try {
await change(r ? "PATCH" : "POST", r ? "/reservations/" + r.id : "/reservations", v);
+54 -3
View File
@@ -1,12 +1,12 @@
// A subnet's page: its leases, its reservations and its settings.
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox } from "../ui.js";
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox, select, field } from "../ui.js";
import { get, change, can } from "../api.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText, orderText } from "../widgets.js";
import { leasesTable } from "./leases.js";
import { reservationsTable, reservationDialog } from "./reservations.js";
import { rerender } from "../app.js";
const tabs = [["leases", "Leases"], ["reservations", "Reservations"], ["settings", "Settings"]];
const tabs = [["leases", "Leases"], ["reservations", "Reservations"], ["access", "Access"], ["settings", "Settings"]];
export async function render(el, params) {
const id = params.id;
@@ -21,7 +21,8 @@ export async function render(el, params) {
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
s.pools.length ? h("span", { class: "mono" }, s.pools.map(poolText).join(", ")) : "none",
orderText(s) ? " · " + orderText(s) : "",
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default",
s.access === "allow-list" ? [" · ", badge("allow-list", "accent")] : "")),
h("div", { class: "subnet-usage" }, usageBar(s.usage)),
can("config", true) ? h("div", { class: "actions" }, h("button", {
title: s.disabled ? "Answer this subnet's clients again" : "Stop answering this subnet's clients; its configuration and leases stay",
@@ -31,9 +32,59 @@ export async function render(el, params) {
body);
if (tab === "leases") await leasesTab(body, s);
else if (tab === "reservations") await reservationsTab(body, s);
else if (tab === "access") accessTab(body, s);
else await settingsTab(body, s);
}
// matchList edits an allow or deny list: entries with a comment each.
function matchList(entries, placeholder) {
const rows = h("div", { class: "pool-rows" });
let list = [];
const addRow = (m) => {
const it = { match: input({ value: m ? m.match : "", placeholder, class: "mono" }), comment: input({ value: m ? m.comment || "" : "", placeholder: "what it is (optional)" }) };
it.row = h("div", { class: "pool-row" }, it.match, it.comment,
h("button", { type: "button", class: "small", title: "Remove", onclick: () => { it.row.remove(); list = list.filter((x) => x !== it); } }, "✕"));
list.push(it);
rows.append(it.row);
return it.match;
};
for (const m of entries || []) addRow(m);
const box = h("div", null, rows, h("button", { type: "button", class: "small", onclick: () => addRow(null).focus() }, "+ Add"));
box.value = () => list.map((it) => ({ match: it.match.value.trim(), comment: it.comment.value.trim() })).filter((m) => m.match);
return box;
}
// accessTab: who the subnet answers.
function accessTab(el, s) {
const mode = select([["deny-list", "Deny-list: every client except those denied"], ["allow-list", "Allow-list: only clients allowed or with a reservation"]], s.access || "deny-list");
const ph = "aa:bb:cc:dd:ee:ff, aa:bb:cc:* or id 01aabb…";
const allow = matchList(s.allow, ph), deny = matchList(s.deny, ph);
// The allow list only counts in an allow-list subnet.
const sync = () => allow.classList.toggle("dimmed", mode.value !== "allow-list");
mode.addEventListener("change", sync);
const err = h("div");
const save = async (dry) => {
mount(err);
try {
const r = await change("PATCH", "/subnets/" + s.id, { access: mode.value, allow: allow.value(), deny: deny.value() }, { query: dry ? { dry_run: 1 } : undefined });
if (!dry && r.applied) rerender();
} catch (e) { mount(err, errorBox(e)); }
};
const form = h("div", { class: "form-grid" },
field("Who gets an address", mode),
field("Allow list", allow, "Used by an allow-list subnet. Clients with a reservation in the subnet are allowed too."),
field("Deny list", deny, "Never answered, in either mode, even with a reservation."));
const write = can("config", true);
mount(el, h("div", { class: "panel" },
h("p", { class: "muted" }, "Entries are a MAC address, a prefix for one maker's devices (aa:bb:cc:*) or a client identifier (id 01aabb…). A refused client gets no answer, and shows as DENIED in Activity; one with a lease gets a NAK when it next renews. Phones use a private MAC address per network unless told not to."),
form, err,
write ? h("div", { class: "actions", style: { marginTop: "14px" } },
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
h("button", { onclick: () => save(true), title: "Check the change without saving it" }, "Check")) : null));
if (!write) readOnly(el.querySelector(".panel"));
sync();
}
export async function toggleSubnet(s) {
if (!s.disabled && !await confirmDialog("Disable subnet", "Stop answering the clients of " + subnetLabel(s) + "? They keep their addresses until their leases run out, then have none. The configuration and leases stay; Enable brings it back.", "Disable", "danger")) return;
try {
+7
View File
@@ -346,6 +346,11 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
/* Activity: one line per message. */
table.activity td { white-space: nowrap; }
table.activity td.detail { white-space: normal; color: var(--muted); }
table.activity tr.event-details > td { white-space: normal; background: var(--bg); }
.event-detail table.kv { border-collapse: collapse; margin: 4px 0; }
.event-detail table.kv th { text-align: left; font-weight: normal; color: var(--muted); padding: 2px 16px 2px 0; vertical-align: top; white-space: nowrap;
text-transform: none; letter-spacing: normal; font-size: inherit; background: none; border: 0; }
.event-detail table.kv td { padding: 2px 0; white-space: normal; word-break: break-word; background: none; border: 0; }
.type-OFFER, .type-ACK { color: var(--ok); }
.type-NAK, .type-DECLINE, .type-CONFLICT, .type-NO { color: var(--err); }
.type-RELEASE { color: var(--muted); }
@@ -355,3 +360,5 @@ input:disabled, select:disabled, textarea:disabled { background: var(--info-soft
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.panel-head h2 { margin: 0; }
pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
.dimmed { opacity: 0.55; }
+33
View File
@@ -234,6 +234,39 @@ run "now the lowest never-used one" 0 udhcpc b 02:00:00:00:00:41
contains "BOUND ip=$NB.10[0-8] "
run "an unknown order is refused" 1 S subnet update iot --add-pool $NB.100-$NB.110:lowest
# ---- a reservation's own lease time
run "reservation lease time" 0 S reservation update $NA.20 --lease-time 2h
run "the reserved client gets it" 0 udhcpc a 02:00:00:00:00:20
contains "BOUND ip=$NA.20 .*lease=7200"
# ---- every option of a message
run "activity with details" 0 S activity --mac 02:00:00:00:00:20 --limit 4 -d
contains "parameter-request-list: "
contains "option 51 lease-time: 7200 s (2h)"
ID=$(S activity --mac 02:00:00:00:00:20 --limit 1 | sed -n 's/^#\([0-9]*\) .*/\1/p' | tail -1)
run "one message" 0 S activity show "$ID"
contains "DHCPACK on eth0"
contains "server-identifier: $NA.2"
# ---- allow and deny lists
run "deny a client" 0 S access deny iot 02:00:00:00:00:50 --comment "test"
run "a denied client gets nothing" 1 udhcpc b 02:00:00:00:00:50
run "activity says it was denied" 0 S activity --mac 02:00:00:00:00:50
contains "DENIED.*deny list of iot .*(test)"
run "allow-list mode" 0 S access mode iot allow-list
run "an unlisted client gets nothing" 1 udhcpc b 02:00:00:00:00:51
run "allow by prefix" 0 S access allow iot "02:00:00:00:00:*"
run "now it gets an address" 0 udhcpc b 02:00:00:00:00:51
contains "BOUND ip=$NB."
run "the deny list still wins" 1 udhcpc b 02:00:00:00:00:50
run "access shows it all" 0 S access show iot
contains "allow-list: 1 allowed"
run "back to a deny-list subnet" 0 S access mode iot deny-list
run "remove the entries" 0 S access remove iot 02:00:00:00:00:50 "02:00:00:00:00:*"
# ---- backup
run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup