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
abrown38andClaude Opus 5.5 abb2ee0fd7 Release 0.1.0-rc.1
release / release (push) Successful in 57s
Steward 0.1.0's first release candidate: 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, the activity log
by subnet, hidden interfaces, and the address order per subnet and pool.
make test and make e2e (121 checks with udhcpc and dhcpcd) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:15:02 +01:00
abrown38andClaude Opus 5.5 8ee430276c Address order: sequential, reverse or random, per subnet and pool
- Which never-used pool address a new client gets: sequential (the
  lowest, as before), reverse or random, in the settings, a subnet or
  a single pool (FIRST-LAST:ORDER). When no never-used address is left,
  the one free the longest, whatever the order; returning clients keep
  theirs.
- A subnet's pools are used in the order listed, or one at random
  weighted by its never-used addresses. Pools now keep the order they
  are given in (no more sorting by address).
- CLI --address-order and --pool-order; --add-pool of an existing range
  changes its order in place. Web UI: per-pool order and a move-up
  button, pool and address order on the subnet and settings pages.
- Kea's allocator: random is imported. Schema migration 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:13:25 +01:00
abrown38andClaude Opus 5.5 29fc991ce1 Reservations page refreshes itself every 15 seconds
Online and last-seen times stay current. A newer draw (typing, a change)
wins over a slower older one; no refresh while the tab is hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:03:06 +01:00
abrown38andClaude Opus 5.5 5e6c61cc8e Hidden interfaces, last-seen pings, activity by subnet
- interfaces.exclude in steward.yaml (names or shell patterns) leaves
  interfaces out of GET /interfaces, the web UI's picker and
  'steward interface list'. Subnets on them are still served.
- With the ping check on, active leases are pinged every minute; a
  client that answers gets a last-seen time (leases.last_seen,
  migration 4). Not answering never takes a client offline.
- The activity log filters by subnet (--subnet, the Activity page,
  GET /events?subnet=).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:50:21 +01:00
57 changed files with 2448 additions and 209 deletions

No files matched your search

+58
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.
@@ -105,6 +138,31 @@ and a CLI, built the way Jarl is.
- **Disabled reservations** (`steward reservation disable|enable`, Disable
in the web UI): kept but ignored, so the client gets a pool address like
any other. Subnets get Disable/Enable buttons in the web UI too.
- **Hidden interfaces:** `interfaces.exclude` in `steward.yaml` (names or
shell patterns such as `docker*`) leaves interfaces out of the web UI's
interface picker, `steward interface list` and `GET /interfaces`.
- **Last seen:** with the ping check on, active leases' addresses are
pinged every minute, and a client that answers gets a last-seen time
(Leases page, reservations' online badge, `steward lease list|show`,
`steward reservation list|show`, `last_seen` in the API). A client that
blocks pings stays online for as long as its lease lasts, without one.
- **The Reservations page refreshes itself** every 15 seconds, so online
and last seen stay current.
- **Activity by subnet:** `steward activity --subnet`, a subnet filter on
the Activity page, `GET /events?subnet=` (ID, name or prefix).
- **Address order** (docs/subnets.md): which never-used pool address a
new client gets, `sequential` (the lowest, as before), `reverse` or
`random`, in the settings, a subnet (`--address-order`) or a single
pool (`--pool 10.0.10.200-10.0.10.250:random`). A subnet's pools are
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 -1
View File
@@ -107,7 +107,14 @@ sudo systemctl disable --now kea-dhcp4-server
## Status
Steward 0.1.0 is in development.
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-dev
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
}
+88 -15
View File
@@ -51,13 +51,13 @@ func leaseCmd() *cobra.Command {
ls = keep
}
return output(ls, func(w io.Writer) {
fmt.Fprintln(w, "IP\tMAC\tHOSTNAME\tSTATE\tENDS\tINTERFACE\tSUBNET")
fmt.Fprintln(w, "IP\tMAC\tHOSTNAME\tSTATE\tENDS\tLAST SEEN\tINTERFACE\tSUBNET")
for _, l := range ls {
st := l.State
if l.Reservation != 0 {
st += ", reserved"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", l.IP, orDash(l.MAC), leaseHost(l), st, fmtWhen(l.End), l.Interface, orDash(l.SubnetName))
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", l.IP, orDash(l.MAC), leaseHost(l), st, fmtWhen(l.End), fmtSeen(l.LastSeen), l.Interface, orDash(l.SubnetName))
}
})
},
@@ -91,6 +91,9 @@ func leaseCmd() *cobra.Command {
fmt.Fprintf(w, "Since:\t%s\n", l.Start.Local().Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, "Ends:\t%s (%s)\n", l.End.Local().Format("2006-01-02 15:04:05"), fmtWhen(l.End))
fmt.Fprintf(w, "Interface:\t%s\n", l.Interface)
if l.LastSeen != nil {
fmt.Fprintf(w, "Last seen:\t%s (%s, answered a ping)\n", l.LastSeen.Local().Format("2006-01-02 15:04:05"), fmtWhen(*l.LastSeen))
}
if l.Reservation != 0 {
fmt.Fprintf(w, "Reservation:\t%d\n", l.Reservation)
}
@@ -123,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()
@@ -143,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
@@ -152,35 +168,46 @@ 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
}
func activityCmd() *cobra.Command {
var mac, iface string
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
@@ -188,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, "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" {
@@ -212,12 +243,49 @@ func activityCmd() *cobra.Command {
}
f := cmd.Flags()
f.StringVar(&mac, "mac", "", "only this client's")
f.StringVar(&subnet, "subnet", "", "only this subnet's (ID, name or prefix); messages without an address, such as DISCOVERs, show for every subnet on their interface")
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{
@@ -245,10 +313,11 @@ func settingsCmd() *cobra.Command {
auth = "no (such requests get no answer)"
}
fmt.Fprintf(w, "Authoritative:\t%s\n", auth)
fmt.Fprintf(w, "Address order:\t%s (subnets and pools can set their own)\n", s.EffectiveAddressOrder())
})
},
})
var leaseTime, ping, pingTimeout, hold, authoritative string
var leaseTime, ping, pingTimeout, hold, authoritative, addrOrder string
var opts, unsetOpts []string
set := &cobra.Command{
Use: "set",
@@ -312,6 +381,9 @@ reservation's own option of the same name wins.
not := !*a
in.NotAuthoritative = &not
}
if fl.Changed("address-order") {
in.AddressOrder = &addrOrder
}
if len(opts) > 0 || len(unsetOpts) > 0 {
var cur model.Settings
if err := c.Get(ctx(), "/settings", nil, &cur); err != nil {
@@ -336,10 +408,11 @@ reservation's own option of the same name wins.
}
f := set.Flags()
f.StringVar(&leaseTime, "lease-time", "", "the default lease time (12h, 1d)")
f.StringVar(&ping, "ping-check", "", "ping addresses before offering them: on or off")
f.StringVar(&ping, "ping-check", "", "ping addresses before offering them, and clients every minute for their last-seen time: on or off")
f.StringVar(&pingTimeout, "ping-timeout", "", "how long to wait for a ping's answer (500ms)")
f.StringVar(&hold, "decline-hold", "", "how long an address in use by someone else is held back (24h)")
f.StringVar(&authoritative, "authoritative", "", "refuse requests for addresses that don't belong on the interface: on or off")
f.StringVar(&addrOrder, "address-order", "", "which never-used pool address a new client gets: sequential (the lowest), reverse (the highest) or random")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE for every subnet")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
cmd.AddCommand(set)
+9 -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
@@ -424,6 +424,14 @@ func fmtWhen(t time.Time) string {
return "in " + s
}
// fmtSeen is a last-seen time, or "-" for a client never seen.
func fmtSeen(t *time.Time) string {
if t == nil {
return "-"
}
return fmtWhen(*t)
}
func orDash(s string) string {
if s == "" {
return "-"
+100 -18
View File
@@ -17,6 +17,20 @@ func subnetPath(ref string) string {
return "/subnets/" + url.PathEscape(strings.Replace(ref, "/", "_", 1))
}
// orderText says how new addresses are chosen: "pools listed, addresses
// the settings' order".
func orderText(s *model.Subnet) string {
pools := "pools in the order listed"
if s.PoolOrder == model.PoolsRandom {
pools = "a pool at random"
}
addrs := "the settings' order"
if s.AddressOrder != "" {
addrs = s.AddressOrder
}
return pools + ", addresses " + addrs + " (unless a pool has its own)"
}
func poolsText(ps []model.Pool) string {
var out []string
for _, p := range ps {
@@ -78,6 +92,8 @@ func subnetCmd() *cobra.Command {
fmt.Fprintf(w, "Interface:\t%s\n", s.Interface)
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"
@@ -96,7 +112,7 @@ func subnetCmd() *cobra.Command {
},
})
var name, iface, leaseTime, comment, prefix string
var name, iface, leaseTime, comment, prefix, poolOrder, addrOrder string
var pools, opts, addPools, removePools, unsetOpts []string
add := &cobra.Command{
Use: "add PREFIX --interface IFACE",
@@ -108,7 +124,11 @@ get its addresses.
--pool 10.0.10.100-10.0.10.199 \
--option routers=10.0.10.1 --option domain-name-servers=10.0.10.2
Pools are FIRST-LAST or a prefix; give --pool once per pool. Options are
Pools are FIRST-LAST or a prefix; give --pool once per pool. A new client
gets a never-used address: from the pools in the order given (or a pool at
random: --pool-order random), the lowest first (or --address-order reverse
or random). A pool can have its own order: --pool 10.0.10.200-10.0.10.250:random.
Options are
NAME=VALUE ('steward option list' has the names); a number works for any
other option, with its type for ones Steward doesn't know: 224:string=hi.`,
Args: cobra.ExactArgs(1),
@@ -134,6 +154,12 @@ other option, with its type for ones Steward doesn't know: 224:string=hi.`,
}
in.LeaseTime = &n
}
if poolOrder != "" {
in.PoolOrder = &poolOrder
}
if addrOrder != "" {
in.AddressOrder = &addrOrder
}
if len(opts) > 0 {
o, err := optionsArg(opts)
if err != nil {
@@ -151,7 +177,9 @@ other option, with its type for ones Steward doesn't know: 224:string=hi.`,
f := add.Flags()
f.StringVar(&iface, "interface", "", "the network interface it is served on (required)")
f.StringVar(&name, "name", "", "a name for it")
f.StringArrayVar(&pools, "pool", nil, "a pool: FIRST-LAST or a prefix (repeat for several)")
f.StringArrayVar(&pools, "pool", nil, "a pool: FIRST-LAST or a prefix, with :ORDER for its own address order (repeat for several)")
f.StringVar(&poolOrder, "pool-order", "", "which pool a new address comes from: listed (in the order given, the default) or random")
f.StringVar(&addrOrder, "address-order", "", "which address of a pool: sequential, reverse or random (default: the settings')")
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d; default: the settings')")
f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE (repeat for several)")
f.StringVar(&comment, "comment", "", "a comment")
@@ -161,11 +189,13 @@ other option, with its type for ones Steward doesn't know: 224:string=hi.`,
update := &cobra.Command{
Use: "update SUBNET",
Short: "Change a subnet",
Long: `Changes a subnet. Only what is given changes. --pool replaces all pools;
--add-pool and --remove-pool change one. --option sets an option (keeping
the others), --unset-option removes one.
Long: `Changes a subnet. Only what is given changes. --pool replaces all pools,
in the order given; --add-pool adds one at the end, or changes the order
of a pool it already has; --remove-pool removes one. --option sets an
option (keeping the others), --unset-option removes one.
steward subnet update office --add-pool 10.0.10.200-10.0.10.220
steward subnet update office --address-order reverse --add-pool 10.0.10.200-10.0.10.220:random
steward subnet update office --option ntp-servers=10.0.10.2 --unset-option time-offset`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -191,6 +221,12 @@ the others), --unset-option removes one.
if fl.Changed("comment") {
in.Comment = &comment
}
if fl.Changed("pool-order") {
in.PoolOrder = &poolOrder
}
if fl.Changed("address-order") {
in.AddressOrder = &addrOrder
}
if fl.Changed("lease-time") {
n := 0
if leaseTime != "default" {
@@ -216,7 +252,7 @@ the others), --unset-option removes one.
}
found := false
for i, p := range list {
if pp, _ := model.ParsePool(p); pp == rp {
if pp, _ := model.ParsePool(p); pp.SameRange(rp) {
list = append(list[:i], list[i+1:]...)
found = true
break
@@ -226,7 +262,21 @@ the others), --unset-option removes one.
return fmt.Errorf("the subnet has no pool %s (it has %s)", rp, poolsText(cur.Pools))
}
}
list = append(list, addPools...)
// A pool it has already changes in place (its order).
adding:
for _, a := range addPools {
ap, err := model.ParsePool(a)
if err != nil {
return err
}
for i, p := range list {
if pp, _ := model.ParsePool(p); pp.SameRange(ap) {
list[i] = a
continue adding
}
}
list = append(list, a)
}
in.Pools = &list
}
if len(opts) > 0 || len(unsetOpts) > 0 {
@@ -252,7 +302,9 @@ the others), --unset-option removes one.
f.StringVar(&iface, "interface", "", "a new interface")
f.StringVar(&prefix, "prefix", "", "a new prefix")
f.StringArrayVar(&pools, "pool", nil, "replace the pools (repeat for several)")
f.StringArrayVar(&addPools, "add-pool", nil, "add a pool")
f.StringArrayVar(&addPools, "add-pool", nil, "add a pool (FIRST-LAST[:ORDER]), or set the order of one it has")
f.StringVar(&poolOrder, "pool-order", "", "which pool a new address comes from: listed or random")
f.StringVar(&addrOrder, "address-order", "", "which address of a pool: sequential, reverse, random or default (the settings')")
f.StringArrayVar(&removePools, "remove-pool", nil, "remove a pool")
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d, or \"default\")")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE")
@@ -334,11 +386,14 @@ 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 {
lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04")
if r.Lease.LastSeen != nil {
lease += ", seen " + fmtWhen(*r.Lease.LastSeen)
}
}
state := "on"
if r.Disabled {
@@ -373,8 +428,16 @@ 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, last seen on %s\n", r.Lease.End.Local().Format("2006-01-02 15:04"), r.Lease.Interface)
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 {
fmt.Fprintf(w, "Last seen:\t%s (answered a ping)\n", fmtWhen(*r.Lease.LastSeen))
}
} else {
fmt.Fprintln(w, "Lease:\tnone now")
}
@@ -410,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()
@@ -440,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 {
@@ -456,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)
@@ -490,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 {
@@ -513,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")
+1
View File
@@ -172,6 +172,7 @@ func serveCmd() *cobra.Command {
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout),
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, PingProblem: pinger.Problem, ACME: acme,
ExcludeInterfaces: a.cfg.Interfaces.Exclude,
}, eng, d, host, a.log, web.FS())
go d.Run(ctx)
hup := make(chan os.Signal, 1)
+23 -9
View File
@@ -62,7 +62,7 @@ Creating answers 201, other changes 200.
|---|---|
| `GET /healthz` | `{"ok": true, "version"}`, without authentication |
| `GET /status` | build, start time, `interfaces` (`listening`, `error`, `addresses`, `subnets`), `subnets` with usage, `problems`, `leases` (counts by state), `messages` (counts by type), `ping_problem` |
| `GET /interfaces` | the host's interfaces, their addresses and subnets |
| `GET /interfaces` | the host's interfaces, their addresses and subnets, leaving out those matching `interfaces.exclude` |
| `GET /catalog` | the option catalog, value types, reserved codes, roles |
### Settings
@@ -71,9 +71,13 @@ Creating answers 201, other changes 200.
```json
{"lease_time": 43200, "ping_check": true, "ping_timeout_ms": 500, "decline_hold": 86400,
"not_authoritative": false, "options": [{"option": "domain-name-servers", "value": "10.0.10.2"}]}
"not_authoritative": false, "address_order": "sequential",
"options": [{"option": "domain-name-servers", "value": "10.0.10.2"}]}
```
`address_order` is `sequential` (left out when it is), `reverse` or
`random` ([subnets.md](subnets.md#address-order)).
`options` replaces the list. Each option is `{"option": NAME or NUMBER,
"value": TEXT}`, with `"type"` for numbers the catalog doesn't know
([options.md](options.md)).
@@ -90,30 +94,40 @@ Creating answers 201, other changes 200.
```json
{"name": "office", "interface": "eth0", "prefix": "10.0.10.0/24",
"pools": ["10.0.10.100-10.0.10.199", "10.0.10.224/28"], "lease_time": 0,
"pools": ["10.0.10.100-10.0.10.199", "10.0.10.224/28:random"], "pool_order": "listed",
"address_order": "default", "lease_time": 0,
"options": [{"option": "routers", "value": "10.0.10.1"}], "disabled": false, "comment": ""}
```
`pools` and `options` replace the lists; `lease_time` 0 means the
settings'.
`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
`default` (the settings'). Subnets come back with pools as
`{"start", "end", "order"}`, and the defaults (`listed`, `default`) left
out. `lease_time` 0 means the settings'.
### Reservations
| | |
|---|---|
| `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
| | |
|---|---|
| `GET /leases[?subnet=&state=&interface=&q=]` | every lease, with its state now (`active`, `expired`, `released`, `declined`), `subnet` and its reservation; `q` searches address, MAC and host name |
| `GET /leases[?subnet=&state=&interface=&q=]` | every lease, with its state now (`active`, `expired`, `released`, `declined`), `subnet` and its reservation, and `last_seen` when the client last answered a ping (left out if it never has); `q` searches address, MAC and host name |
| `GET /leases/{ip}` | one |
| `DELETE /leases/{ip}` | forget it (audited) |
| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment"}` |
| `GET /events[?mac=&interface=&limit=]` | the latest DHCP messages, newest first (the last 1000 are kept in memory) |
| `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
+49 -15
View File
@@ -62,8 +62,8 @@ since the start, and problems.
|---|---|
| `subnet list` | subnets, with interface, pools, use, reservations, lease time |
| `subnet show SUBNET` | one subnet; SUBNET is its ID, name or prefix |
| `subnet add PREFIX --interface IFACE [--name N] [--pool A-B]... [--lease-time 12h] [--option NAME=VALUE]... [--comment C]` | add one |
| `subnet update SUBNET [--name] [--interface] [--prefix] [--pool]... [--add-pool] [--remove-pool] [--lease-time 1h\|default] [--option NAME=VALUE]... [--unset-option NAME]... [--comment]` | change what is given |
| `subnet add PREFIX --interface IFACE [--name N] [--pool A-B[:ORDER]]... [--pool-order listed\|random] [--address-order sequential\|reverse\|random] [--lease-time 12h] [--option NAME=VALUE]... [--comment C]` | add one |
| `subnet update SUBNET [--name] [--interface] [--prefix] [--pool]... [--add-pool] [--remove-pool] [--pool-order] [--address-order ...\|default] [--lease-time 1h\|default] [--option NAME=VALUE]... [--unset-option NAME]... [--comment]` | change what is given; `--add-pool` of a pool it has changes that pool's order ([subnets.md](subnets.md#address-order)) |
| `subnet enable\|disable SUBNET` | answer its clients, or not |
| `subnet delete SUBNET [-y]` | delete it and its reservations |
@@ -75,35 +75,69 @@ one. An option Steward doesn't know: `--option 224:string=hello`.
| Command | |
|---|---|
| `reservation list [--subnet SUBNET]` | reservations, and whether each client is online |
| `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] [--interface I] [--limit N] [-f]`: the latest
DHCP messages, received and sent, oldest first. `-f` keeps watching, which
is useful while a device tries to get an address:
`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.
@@ -113,9 +147,9 @@ sent.
| Command | |
|---|---|
| `settings show` | |
| `settings set [--lease-time 1d] [--ping-check on\|off] [--ping-timeout 500ms] [--decline-hold 24h] [--authoritative on\|off] [--option NAME=VALUE]... [--unset-option NAME]...` | |
| `settings set [--lease-time 1d] [--ping-check on\|off] [--ping-timeout 500ms] [--decline-hold 24h] [--authoritative on\|off] [--address-order sequential\|reverse\|random] [--option NAME=VALUE]... [--unset-option NAME]...` | |
| `option list` | the options Steward knows ([options.md](options.md)) |
| `interface list` | the server's interfaces, their addresses and subnets |
| `interface list` | the server's interfaces, their addresses and subnets (not those in `interfaces.exclude`) |
### history
+1
View File
@@ -14,6 +14,7 @@ the file when it starts.
| `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; stewardd rereads it when the file changes, or on `systemctl reload steward` |
| `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) |
| `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server Steward uses for DNS, and its token ([jarl.md](jarl.md)) |
| `interfaces.exclude` | `[]` | interfaces left out of the web UI's interface picker, `steward interface list` and `GET /interfaces`: names or shell patterns, such as `["lo", "docker*", "veth*"]`. A subnet can still name one, and is still served |
| `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed |
| `socket` | `/run/steward/steward.sock` | the local API socket; `""` turns it off |
| `database` | `/var/lib/steward/steward.db` | the SQLite database: configuration, leases, history, users, audit log |
+3 -1
View File
@@ -25,7 +25,9 @@ check or reload.
* **Leases** in SQLite, written before the client is answered, so a crash
or restart never hands out an address twice.
* **Conflict checks**: an address is pinged before it is offered; one that
answers, or that a client declines, is held back for a while.
answers, or that a client declines, is held back for a while. Active
leases are pinged every minute too, for a "last seen" time that never
takes a client offline ([subnets.md](subnets.md#online-and-last-seen)).
* **The Jarl way of working**: an API used by both the CLI and the web UI;
users, sessions and API tokens; every configuration change validated as a
whole before it is saved and used; a history of changes that can be
+1
View File
@@ -55,6 +55,7 @@ It checks:
* addresses and options per interface, and reservations;
* the ping check, against a container that squats on a pool address;
* last seen: a client that stays up answers pings, one that left has none;
* init-reboot, renewal and release with dhcpcd;
* a client moved to another VLAN: NAK, then that VLAN's address;
* a full pool and a disabled subnet;
+1
View File
@@ -51,6 +51,7 @@ pings before it offers an address, and clients renew or rebind.
| `shared-networks` | their subnets, all on the shared network's interface |
| `reservations` with `hw-address` or `client-id` and `ip-address`, `hostname` | reservations |
| `decline-probation-period` | the decline hold |
| `allocator` (global, shared network, subnet) | `random`: the subnet's pool and address order random. `iterative` and `flq` become the default order, the lowest never-used address (with a note for `flq`) |
| `user-context.comment` or `comment` of a subnet | the subnet's comment |
| the lease file: active and declined leases, and released ones | leases (the last line of each address wins) |
+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
+103 -8
View File
@@ -20,7 +20,9 @@ For VLANs, give the server an interface on each, such as `eth0.20`, or a
bridge such as `br-iot`, each with an address in its subnet. That address
is the server identifier clients see, and the one they renew their lease
with. `steward interface list` shows the server's interfaces with their
addresses and subnets.
addresses and subnets. To keep interfaces such as `lo`, `docker0` or
`veth*` out of that list and the web UI's picker, name them in
`interfaces.exclude` in steward.yaml ([configuration.md](configuration.md)).
An interface that doesn't exist yet is fine. The subnet gets a warning
(`interface_unknown`), and stewardd starts serving it within 30 seconds of
@@ -54,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.
@@ -106,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.
@@ -119,8 +129,9 @@ For a DISCOVER, Steward offers, in this order:
ended, as long as it is still free and in a pool;
3. the address the client asks for (option 50), if it is free and in a
pool;
4. a free pool address: one never handed out before, else the one that has
been free the longest.
4. a free pool address: one never handed out before, in the subnet's
[address order](#address-order), else the one that has been free the
longest.
An address is free when no other client has an active lease on it, it
isn't held back, and it isn't offered to someone else in the last minute.
@@ -131,6 +142,40 @@ tried.
When the client confirms with a REQUEST, the lease is saved, and only
then is the ACK sent.
## Address order
Which never-used address a new client gets is set at three levels, each
overriding the one above:
| Level | Setting | Values |
|---|---|---|
| settings | `steward settings set --address-order` | `sequential` (the lowest free address, the default), `reverse` (the highest), `random` |
| subnet | `--address-order` | the same, or `default` (the settings') |
| pool | `:ORDER` after the pool: `--pool 10.0.10.200-10.0.10.250:random` | the same, or `default` (the subnet's) |
A subnet with several pools also has a **pool order**, `--pool-order`:
* `listed` (the default): the pools are used in the order they are
listed; the next one only when the one before has no never-used address
left. `--pool` gives the order; in the web UI, ↑ moves a pool up.
* `random`: a pool at random, weighted by how many never-used addresses
each has, so addresses spread over all of them. With `random` addresses
too, every never-used address in the subnet is as likely as any other.
```
steward subnet update office --pool-order random --address-order reverse
steward subnet update office --add-pool 10.0.10.200-10.0.10.250:random # one pool's own order
steward subnet update office --add-pool 10.0.10.200-10.0.10.250:default # back to the subnet's
```
The order only picks among addresses **never handed out** (or whose lease
was deleted, or ended over 30 days ago). Once every free address has been
used before, the one free the longest is given, whatever the order: an
address that just came free isn't handed to the next device, so stale ARP
caches and DNS records don't point at the wrong one. Returning clients
keep their address (steps 2 and 3), so changing the order doesn't move
anyone.
## Leases
| State | Meaning |
@@ -143,9 +188,59 @@ then is the ACK sent.
`steward lease list` shows active and held-back leases; `--all` shows
the others too. Leases that ended more than 30 days ago are deleted.
### Online and last seen
A client is **online** while its lease is active. With the ping check on
(the default), Steward also pings every active lease's address once a
minute, and a client that answers gets a **last seen** time: on the
Leases page, next to a reservation's online badge, and in `steward lease
list`. Many devices block pings (Windows' firewall does by default), so a
client that doesn't answer stays online and simply has no last-seen time,
or keeps the one it had. The time stays with the lease when it is
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
+7
View File
@@ -17,6 +17,7 @@ interfaces. They come from stewardd, not from a configuration on its own.
| `lease_time_range` | error | a lease time (settings or subnet) under 1 minute or over 365 days |
| `ping_timeout_range` | error | the ping timeout isn't 50 to 5000 ms |
| `decline_hold_range` | error | the decline hold isn't 1 minute to 30 days |
| `address_order` | error | an address order (settings, subnet or pool) that isn't `sequential`, `reverse` or `random` |
## Options
@@ -45,6 +46,7 @@ These apply to options anywhere (settings, subnets, reservations).
| `pool_outside_subnet` | error | a pool not inside the subnet |
| `pool_network_address` | error | a pool with the network or broadcast address |
| `pool_overlap` | error | two pools overlap |
| `pool_order` | error | a pool order that isn't `listed` or `random` |
| `router_outside_subnet` | warning | a `routers` address outside the subnet: clients can't reach it |
| `router_in_pool` | warning | a `routers` address inside a pool. The ping check keeps it from being handed out while it answers, but take it out of the pool |
| `interface_unknown` | warning, host | the interface doesn't exist on this machine. It is served once it appears |
@@ -61,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 |
+37 -11
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
"steward/internal/validate"
"steward/internal/version"
@@ -68,19 +69,30 @@ type SettingsInput struct {
PingTimeout *int `json:"ping_timeout_ms,omitempty"`
DeclineHold *int `json:"decline_hold,omitempty"`
NotAuthoritative *bool `json:"not_authoritative,omitempty"`
// AddressOrder is sequential, reverse or random.
AddressOrder *string `json:"address_order,omitempty"`
}
// SubnetInput creates or changes a subnet (unset fields stay). Pools are
// "FIRST-LAST" or prefixes.
// "FIRST-LAST" or prefixes, with ":ORDER" for a pool's own address order.
type SubnetInput struct {
Name *string `json:"name,omitempty"`
Interface *string `json:"interface,omitempty"`
Prefix *string `json:"prefix,omitempty"`
Pools *[]string `json:"pools,omitempty"`
LeaseTime *int `json:"lease_time,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
Name *string `json:"name,omitempty"`
Interface *string `json:"interface,omitempty"`
Prefix *string `json:"prefix,omitempty"`
Pools *[]string `json:"pools,omitempty"`
// PoolOrder is listed or random; AddressOrder sequential, reverse,
// random or "" (the settings').
PoolOrder *string `json:"pool_order,omitempty"`
AddressOrder *string `json:"address_order,omitempty"`
// Access is deny-list or allow-list; Allow and Deny replace the
// lists (entries: a MAC address, aa:bb:cc:* or "id HEX").
Access *string `json:"access,omitempty"`
Allow *[]model.ClientMatch `json:"allow,omitempty"`
Deny *[]model.ClientMatch `json:"deny,omitempty"`
LeaseTime *int `json:"lease_time,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// ReservationInput creates or changes a reservation (unset fields stay).
@@ -90,8 +102,10 @@ type ReservationInput struct {
IP *string `json:"ip,omitempty"`
Hostname *string `json:"hostname,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,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"`
}
// Subnet is a subnet with its usage.
@@ -247,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.
+24
View File
@@ -10,6 +10,7 @@ import (
"net/netip"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
@@ -35,6 +36,17 @@ type Config struct {
Backup Backup `yaml:"backup"`
// Jarl is the Jarl server Steward uses for DNS (docs/jarl.md).
Jarl Jarl `yaml:"jarl"`
// Interfaces says which of the host's interfaces are offered when
// choosing a subnet's interface.
Interfaces Interfaces `yaml:"interfaces"`
}
// Interfaces filters the host's interfaces listed by the API, the web UI's
// interface picker and 'steward interface list'.
type Interfaces struct {
// Exclude are interface names or shell patterns ("docker*", "veth*")
// left out of the list. It doesn't stop a subnet from naming one.
Exclude []string `yaml:"exclude"`
}
// TLS is the web UI's certificate: from files, or obtained through ACME
@@ -173,6 +185,7 @@ func Default() *Config {
return &Config{
Listen: []string{"127.0.0.1:8067"},
TrustedProxies: []string{},
Interfaces: Interfaces{Exclude: []string{}},
Socket: "/run/steward/steward.sock",
Database: "/var/lib/steward/steward.db",
Log: Log{Level: "info", Format: "text"},
@@ -225,6 +238,11 @@ func (c *Config) check() error {
return fmt.Errorf("trusted_proxies: %v", err)
}
}
for _, p := range c.Interfaces.Exclude {
if _, err := path.Match(p, ""); err != nil || p == "" {
return fmt.Errorf("interfaces.exclude: %q is not an interface name or pattern (e.g. lo, docker*, veth*)", p)
}
}
if c.Database == "" || !filepath.IsAbs(c.Database) {
return fmt.Errorf("database must be an absolute path")
}
@@ -354,6 +372,12 @@ tls:
# ca_cert: "" # a CA to trust for Jarl's certificate
# insecure: false
# Interfaces left out when choosing a subnet's interface (the web UI's
# picker, 'steward interface list'): names or shell patterns. Subnets
# already on one are still served.
interfaces:
exclude: [] # e.g. ["lo", "docker*", "veth*", "br-*"]
# Reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are
# believed.
trusted_proxies: []
+1
View File
@@ -34,6 +34,7 @@ func TestErrors(t *testing.T) {
"{tls: {cert_file: /a, key_file: /b, acme: {domain: d.example.com}}, jarl: {server: https://j, token: x}}": "can't both",
"{tls: {acme: {domain: '*.example.com'}}, jarl: {server: https://j, token: x}}": "full name",
"{tls: {acme: {domain: d.example.com, directory: http://x}}, jarl: {server: https://j, token: x}}": "https://",
"interfaces: {exclude: ['eth[']}": "interfaces.exclude",
}
for yml, want := range cases {
path := filepath.Join(t.TempDir(), "s.yaml")
+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 {
+73 -27
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)
@@ -156,29 +179,24 @@ func (s *Server) choose(cfg *config, iface string, subs []*subnet, cl client, re
return hold(requested, sub, true)
}
}
// 4. A free address: one never handed out, else the one free the
// longest.
// 4. A free address: one never handed out, in the subnet's order
// (order.go), else the one free the longest.
for _, sub := range subs {
if ip, ok := s.neverUsed(cfg, own, sub, cl, now); ok {
return hold(ip, sub, true)
}
}
var oldest netip.Addr
var oldestSub *subnet
var oldestEnd time.Time
for _, sub := range subs {
for _, pool := range sub.Pools {
for v := model.U32(pool.Start); v <= model.U32(pool.End) && v != 0; v++ {
ip := model.FromU32(v)
if !s.usable(cfg, own, sub, ip, cl, now) {
continue
}
l := s.leases[ip]
if l == nil {
return hold(ip, sub, true)
}
if !oldest.IsValid() || l.End.Before(oldestEnd) {
eachIn(pool, false, func(ip netip.Addr) bool {
if l := s.leases[ip]; l != nil && s.usable(cfg, own, sub, ip, cl, now) && (!oldest.IsValid() || l.End.Before(oldestEnd)) {
oldest, oldestSub, oldestEnd = ip, sub, l.End
}
if v == model.U32(pool.End) {
break
}
}
return false
})
}
}
if oldest.IsValid() {
@@ -195,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 {
@@ -216,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 {
@@ -239,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 {
@@ -278,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()
@@ -290,7 +322,9 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa
return s.nak(cfg, iface, p, sub, why)
}
start, host := now, p.HostName()
var seen *time.Time
if l := s.leases[ip]; l != nil && l.Owns(cl.mac, cl.id) {
seen = l.LastSeen
if l.ActiveAt(now) {
start = l.Start
}
@@ -299,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}
Start: start, End: now.Add(leaseTime(sub, res)), Interface: iface, LastSeen: seen}
err := s.putLease(ctx, l)
delete(s.offers, ip)
s.mu.Unlock()
@@ -315,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
@@ -402,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
@@ -416,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.
@@ -428,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))
+201 -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)
@@ -461,3 +465,198 @@ func TestDisabledReservation(t *testing.T) {
t.Errorf("the printer got %s from a full pool", out.Packet.YIAddr)
}
}
func TestLastSeen(t *testing.T) {
h := newHarness(t, nil)
up := h.lease("eth0", mac(1)).YIAddr
quiet := h.lease("eth0", mac(2)).YIAddr
h.ping.answers[up] = true
h.s.CheckSeen(context.Background())
if l := h.s.Lease(up); l.LastSeen == nil || !l.LastSeen.Equal(h.clock) {
t.Errorf("answering client: %+v", l)
}
// One that never answers has no last-seen time, and stays active.
if l := h.s.Lease(quiet); l.LastSeen != nil || !l.ActiveAt(h.clock) {
t.Errorf("quiet client: %+v", l)
}
// Later, the time stays when it stops answering, and survives a
// renewal and a restart.
seen := h.clock
h.clock = h.clock.Add(10 * time.Minute)
h.ping.answers[up] = false
h.s.CheckSeen(context.Background())
h.lease("eth0", mac(1))
if l := h.s.Lease(up); l.LastSeen == nil || !l.LastSeen.Equal(seen) {
t.Errorf("after a renewal: %+v", l)
}
ls, _ := h.st.LoadLeases(context.Background())
for _, l := range ls {
if l.IP == up && (l.LastSeen == nil || !l.LastSeen.Equal(seen)) {
t.Errorf("stored: %+v", l)
}
}
// Expired leases aren't pinged.
h.clock = h.clock.Add(48 * time.Hour)
h.ping.pinged = nil
h.s.CheckSeen(context.Background())
if len(h.ping.pinged) != 0 {
t.Errorf("pinged %v", h.ping.pinged)
}
}
func TestAddressOrder(t *testing.T) {
ip := func(o *dhcp4.Packet) string { return o.YIAddr.String() }
twoPools := func(st *model.State) {
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.10-10.0.20.19"), pool("10.0.20.100-10.0.20.109")}
}
// Sequential (the default): the lowest of the first pool listed.
h := newHarness(t, twoPools)
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.10" {
t.Errorf("sequential: %s", got)
}
// Never-used addresses first: one that came free isn't the next.
h.clock = h.clock.Add(2 * time.Hour)
if got := ip(h.lease("eth0.20", mac(2))); got != "10.0.20.11" {
t.Errorf("after an expiry: %s", got)
}
// But the client that had it gets it back, whatever the order.
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.10" {
t.Errorf("returning client: %s", got)
}
// Reverse from the settings.
h = newHarness(t, func(st *model.State) { twoPools(st); st.Settings.AddressOrder = model.OrderReverse })
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.19" {
t.Errorf("reverse: %s", got)
}
// The pools in the order listed; a pool's own order wins over the
// subnet's, which wins over the settings'.
h = newHarness(t, func(st *model.State) {
st.Subnets[1].Pools = []model.Pool{{Start: netip.MustParseAddr("10.0.20.100"), End: netip.MustParseAddr("10.0.20.109"), Order: model.OrderSequential}, pool("10.0.20.10-10.0.20.19")}
st.Subnets[1].AddressOrder = model.OrderReverse
st.Settings.AddressOrder = model.OrderRandom
})
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.100" {
t.Errorf("pool's own order: %s", got)
}
// Full first pool: the next one, in the subnet's order (reverse).
h = newHarness(t, func(st *model.State) {
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.10-10.0.20.11"), pool("10.0.20.100-10.0.20.109")}
st.Subnets[1].AddressOrder = model.OrderReverse
})
got := []string{ip(h.lease("eth0.20", mac(1))), ip(h.lease("eth0.20", mac(2))), ip(h.lease("eth0.20", mac(3)))}
if strings.Join(got, " ") != "10.0.20.11 10.0.20.10 10.0.20.109" {
t.Errorf("pool by pool: %v", got)
}
// Random inside a pool: the n-th never-used address.
h = newHarness(t, func(st *model.State) { twoPools(st); st.Subnets[1].AddressOrder = model.OrderRandom })
h.s.randN = func(n int) int { return 3 }
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.13" {
t.Errorf("random: %s", got)
}
if got := ip(h.lease("eth0.20", mac(2))); got != "10.0.20.14" {
t.Errorf("random, skipping the used one: %s", got)
}
// Random pools, weighted by never-used addresses: 10 + 10, so 12 is
// in the second pool, which then uses its own order.
h = newHarness(t, func(st *model.State) { twoPools(st); st.Subnets[1].PoolOrder = model.PoolsRandom })
var asked []int
h.s.randN = func(n int) int { asked = append(asked, n); return 12 }
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.100" || asked[0] != 20 {
t.Errorf("random pools: %s %v", got, asked)
}
// Real randomness stays inside the pools and never repeats.
h = newHarness(t, func(st *model.State) {
twoPools(st)
st.Subnets[1].PoolOrder = model.PoolsRandom
st.Subnets[1].AddressOrder = model.OrderRandom
})
seen := map[string]bool{}
for i := byte(1); i <= 20; i++ {
a := ip(h.lease("eth0.20", mac(i)))
if seen[a] || !(strings.HasPrefix(a, "10.0.20.1") && len(a) >= 10) {
t.Fatalf("random address %s (have %v)", a, seen)
}
seen[a] = true
}
if out := h.discover("eth0.20", mac(21)); out != nil {
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)
}
}
+1
View File
@@ -46,6 +46,7 @@ func (s *Server) Run(ctx context.Context) {
defer t.Stop()
prune := time.NewTicker(time.Hour)
defer prune.Stop()
go s.watchSeen(ctx)
for {
s.reconcile(ctx)
select {
+117
View File
@@ -0,0 +1,117 @@
package dhcpd
import (
"net/netip"
"time"
"steward/internal/model"
)
// neverUsed picks a pool address that was never handed out (or whose
// lease was deleted), in the subnet's order (s.mu held): the pools listed
// or at random, and inside a pool sequential, reverse or random
// (docs/subnets.md, "Address order").
func (s *Server) neverUsed(cfg *config, own map[netip.Addr]bool, sub *subnet, cl client, now time.Time) (netip.Addr, bool) {
ok := func(ip netip.Addr) bool { return s.leases[ip] == nil && s.usable(cfg, own, sub, ip, cl, now) }
order := func(p model.Pool) string {
switch {
case p.Order != "":
return p.Order
case sub.AddressOrder != "":
return sub.AddressOrder
}
return cfg.settings.EffectiveAddressOrder()
}
if sub.PoolOrder != model.PoolsRandom {
for _, p := range sub.Pools {
if ip, found := s.pickIn(p, order(p), ok); found {
return ip, true
}
}
return netip.Addr{}, false
}
// A pool at random, weighted by its never-used addresses.
counts := make([]int, len(sub.Pools))
total := 0
for i, p := range sub.Pools {
counts[i] = countIn(p, ok)
total += counts[i]
}
if total == 0 {
return netip.Addr{}, false
}
n := s.randN(total)
for i, p := range sub.Pools {
if n < counts[i] {
return s.pickIn(p, order(p), ok)
}
n -= counts[i]
}
return netip.Addr{}, false
}
// pickIn picks an address of the pool that ok accepts, in an order.
func (s *Server) pickIn(p model.Pool, order string, ok func(netip.Addr) bool) (netip.Addr, bool) {
var found netip.Addr
switch order {
case model.OrderRandom:
n := countIn(p, ok)
if n == 0 {
return netip.Addr{}, false
}
k := s.randN(n)
eachIn(p, false, func(ip netip.Addr) bool {
if !ok(ip) {
return false
}
if k == 0 {
found = ip
return true
}
k--
return false
})
default:
eachIn(p, order == model.OrderReverse, func(ip netip.Addr) bool {
if ok(ip) {
found = ip
return true
}
return false
})
}
return found, found.IsValid()
}
// countIn counts the pool's addresses that ok accepts.
func countIn(p model.Pool, ok func(netip.Addr) bool) int {
n := 0
eachIn(p, false, func(ip netip.Addr) bool {
if ok(ip) {
n++
}
return false
})
return n
}
// eachIn calls fn for the pool's addresses, lowest first (highest first
// when reverse), until it returns true.
func eachIn(p model.Pool, reverse bool, fn func(netip.Addr) bool) {
if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) {
return
}
lo, hi := model.U32(p.Start), model.U32(p.End)
if reverse {
for v := hi; ; v-- {
if fn(model.FromU32(v)) || v == lo {
return
}
}
}
for v := lo; ; v++ {
if fn(model.FromU32(v)) || v == hi {
return
}
}
}
+2 -2
View File
@@ -31,7 +31,7 @@ func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Durati
conn, err := icmp.ListenPacket("udp4", "0.0.0.0")
if err != nil {
p.once.Do(func() {
msg := "can't send pings, so addresses aren't checked before they are offered: " + err.Error() + " (check net.ipv4.ping_group_range)"
msg := "can't send pings, so addresses aren't checked before they are offered and clients' last-seen times aren't known: " + err.Error() + " (check net.ipv4.ping_group_range)"
p.Unavailable.Store(msg)
if p.Log != nil {
p.Log.Warn(msg)
@@ -41,7 +41,7 @@ func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Durati
}
defer conn.Close()
seq := int(p.seq.Add(1) & 0xffff)
msg := icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: os.Getpid() & 0xffff, Seq: seq, Data: []byte("steward conflict check")}}
msg := icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: os.Getpid() & 0xffff, Seq: seq, Data: []byte("steward")}}
b, err := msg.Marshal(nil)
if err != nil {
return false
+95
View File
@@ -0,0 +1,95 @@
package dhcpd
import (
"context"
"net/netip"
"sync"
"time"
"steward/internal/model"
)
// How often active leases' addresses are pinged for their last-seen time,
// and how many pings run at once.
const (
seenEvery = time.Minute
seenWorkers = 16
)
// watchSeen pings the active leases' addresses every seenEvery.
func (s *Server) watchSeen(ctx context.Context) {
t := time.NewTicker(seenEvery)
defer t.Stop()
for {
s.CheckSeen(ctx)
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
// CheckSeen pings every active lease's address once (when the ping check
// is on), and records when each that answered was seen. A client that
// doesn't answer keeps its last time, or none: many block pings, and stay
// online while their lease lasts.
func (s *Server) CheckSeen(ctx context.Context) {
cfg := s.cfg.Load()
if s.pinger == nil || !cfg.settings.PingCheck {
return
}
now := s.now()
var active []model.Lease
s.mu.Lock()
for _, l := range s.leases {
if l.ActiveAt(now) {
active = append(active, *l)
}
}
s.mu.Unlock()
timeout := cfg.settings.EffectivePingTimeout()
var (
mu sync.Mutex
seen = map[netip.Addr]time.Time{}
wg sync.WaitGroup
sem = make(chan struct{}, seenWorkers)
)
for _, l := range active {
if ctx.Err() != nil {
break
}
sem <- struct{}{}
wg.Add(1)
go func() {
defer func() { <-sem; wg.Done() }()
if s.pinger.Ping(ctx, l.IP, timeout) {
mu.Lock()
seen[l.IP] = s.now()
mu.Unlock()
}
}()
}
wg.Wait()
if len(seen) == 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, was := range active {
at, ok := seen[was.IP]
if !ok {
continue
}
// The address may have gone to another client meanwhile.
l := s.leases[was.IP]
if l == nil || l.MAC != was.MAC || l.ClientID != was.ClientID || !l.ActiveAt(at) {
delete(seen, was.IP)
continue
}
l.LastSeen = &at
}
if err := s.store.SetLastSeen(ctx, seen); err != nil {
s.warnOnce("last seen", time.Hour, "saving clients' last-seen times failed", "err", err)
}
}
+39 -7
View File
@@ -7,14 +7,15 @@ import (
"context"
"errors"
"log/slog"
"math/rand/v2"
"net"
"net/netip"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
)
@@ -24,6 +25,7 @@ type LeaseStore interface {
PutLease(ctx context.Context, l *model.Lease) error
DeleteLease(ctx context.Context, ip netip.Addr) error
PruneLeases(ctx context.Context, before time.Time) (int64, error)
SetLastSeen(ctx context.Context, seen map[netip.Addr]time.Time) error
}
// Interfaces reports a network interface's IPv4 addresses with their
@@ -32,7 +34,8 @@ type Interfaces interface {
Addrs(name string) ([]netip.Prefix, error)
}
// Pinger checks whether an address answers (conflict detection).
// Pinger checks whether an address answers (conflict detection, and
// clients' last-seen times).
type Pinger interface {
Ping(ctx context.Context, a netip.Addr, timeout time.Duration) bool
}
@@ -51,6 +54,8 @@ type Server struct {
ifaces Interfaces
pinger Pinger
now func() time.Time
// randN returns a random number in [0, n) (address order random).
randN func(n int) int
// ServerPort and ClientPort are 67 and 68 (tests use others).
ServerPort, ClientPort int
@@ -81,7 +86,7 @@ func New(log *slog.Logger, st LeaseStore, ifaces Interfaces, pinger Pinger) *Ser
if ifaces == nil {
ifaces = &HostInterfaces{}
}
s := &Server{log: log, store: st, ifaces: ifaces, pinger: pinger, now: time.Now, ServerPort: 67, ClientPort: 68,
s := &Server{log: log, store: st, ifaces: ifaces, pinger: pinger, now: time.Now, randN: rand.IntN, ServerPort: 67, ClientPort: 68,
leases: map[netip.Addr]*model.Lease{}, offers: map[netip.Addr]offer{}, changed: make(chan struct{}, 1)}
s.cfg.Store(compile(&model.State{Settings: model.DefaultSettings()}))
s.events.max = 1000
@@ -320,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 {
@@ -327,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:]...)
@@ -342,15 +362,15 @@ func (e *events) add(ev Event) {
e.counts[ev.Type]++
}
// Events returns the latest events, newest first, optionally only those of
// one MAC address or interface.
func (s *Server) Events(limit int, mac, iface string) []Event {
// Events returns the latest events, newest first, optionally only those
// keep accepts.
func (s *Server) Events(limit int, keep func(Event) bool) []Event {
s.events.mu.Lock()
defer s.events.mu.Unlock()
out := []Event{}
for i := len(s.events.list) - 1; i >= 0 && (limit <= 0 || len(out) < limit); i-- {
ev := s.events.list[i]
if mac != "" && !strings.EqualFold(ev.MAC, mac) || iface != "" && ev.Interface != iface {
if keep != nil && !keep(ev) {
continue
}
out = append(out, ev)
@@ -358,6 +378,18 @@ func (s *Server) Events(limit int, mac, iface string) []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 {
+16 -1
View File
@@ -415,6 +415,9 @@ func put(st *model.State, kind string, id int64, data json.RawMessage) error {
// lists in their canonical form, so equal configurations compare equal.
func Normalize(st *model.State) {
st.Settings.Options = normOptions(st.Settings.Options)
if st.Settings.AddressOrder == model.OrderSequential {
st.Settings.AddressOrder = "" // the default
}
if st.Subnets == nil {
st.Subnets = []*model.Subnet{}
}
@@ -429,7 +432,19 @@ func Normalize(st *model.State) {
if s.Prefix.IsValid() && s.Prefix.Addr().Is4() {
s.Prefix = s.Prefix.Masked()
}
sort.SliceStable(s.Pools, func(i, j int) bool { return s.Pools[i].Start.Less(s.Pools[j].Start) })
// The pools' order counts (pool_order listed): it is kept.
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)
+28 -1
View File
@@ -90,6 +90,9 @@ type importer struct {
opts Options
res *Result
globals []string // interfaces-config interfaces
// allocator is Dhcp4's allocator (iterative, random or flq).
allocator string
noted map[string]bool
}
// ParseConfig reads a kea-dhcp4.conf.
@@ -131,6 +134,17 @@ func jsonError(b []byte, err error) error {
return err
}
// noteOnce adds a note once per key.
func (im *importer) noteOnce(key, format string, args ...any) {
if im.noted == nil {
im.noted = map[string]bool{}
}
if !im.noted[key] {
im.noted[key] = true
im.note(format, args...)
}
}
func (im *importer) note(format string, args ...any) {
im.res.Notes = append(im.res.Notes, fmt.Sprintf(format, args...))
}
@@ -154,7 +168,7 @@ func get[T any](o obj, key string, into *T) (bool, error) {
// note.
var (
handledKeys = map[string]bool{"interfaces-config": true, "valid-lifetime": true, "option-data": true, "subnet4": true, "shared-networks": true,
"reservations": true, "authoritative": true, "decline-probation-period": true}
"reservations": true, "authoritative": true, "decline-probation-period": true, "allocator": true}
ignoredKeys = map[string]string{
"lease-database": "Steward keeps leases in its own database", "control-socket": "Steward has its own API",
"loggers": "Steward logs to the journal", "renew-timer": "Steward sends half the lease time",
@@ -195,6 +209,7 @@ func (im *importer) dhcp4(d obj) error {
im.globals = append(im.globals, name)
}
}
get(d, "allocator", &im.allocator)
set := &im.res.State.Settings
var vl int
if ok, err := get(d, "valid-lifetime", &vl); err != nil {
@@ -346,6 +361,18 @@ func (im *importer) subnet(s obj, shared obj) error {
}
sn.Options = so
}
alloc := im.allocator
if shared != nil {
get(shared, "allocator", &alloc)
}
get(s, "allocator", &alloc)
switch alloc {
case "random":
// Kea picks among all the subnet's free addresses.
sn.PoolOrder, sn.AddressOrder = model.PoolsRandom, model.OrderRandom
case "flq":
im.noteOnce("flq", "the allocator flq (free lease queue) became Steward's sequential order: a new client gets the lowest address never handed out, else the one free the longest")
}
if ok, _ := get(s, "interface", &iface); ok && iface == "" {
iface = ""
}
+21
View File
@@ -168,3 +168,24 @@ func TestParseLeases(t *testing.T) {
t.Error("a file without Kea's columns was accepted")
}
}
func TestAllocator(t *testing.T) {
cfg := `{"Dhcp4": {"allocator": "random", "subnet4": [
{"subnet": "192.168.5.0/24", "interface": "eth0", "pools": [{"pool": "192.168.5.10-192.168.5.20"}]},
{"subnet": "192.168.6.0/24", "interface": "eth1", "allocator": "iterative", "pools": [{"pool": "192.168.6.10-192.168.6.20"}]}]}}`
res, err := ParseConfig([]byte(cfg), Options{})
if err != nil {
t.Fatal(err)
}
if s := res.State.Subnets[0]; s.PoolOrder != model.PoolsRandom || s.AddressOrder != model.OrderRandom {
t.Errorf("random: %+v", s)
}
if s := res.State.Subnets[1]; s.PoolOrder != "" || s.AddressOrder != "" {
t.Errorf("iterative: %+v", s)
}
for _, sk := range res.Skipped {
if strings.Contains(sk, "allocator") {
t.Errorf("skipped %q", sk)
}
}
}
+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()
}
+86 -3
View File
@@ -48,6 +48,45 @@ type Settings struct {
// addresses that don't belong on the interface, for networks where
// another DHCP server shares the segment. Off by default.
NotAuthoritative bool `json:"not_authoritative"`
// AddressOrder is which never-used pool address a new client gets:
// sequential (the lowest, the default), reverse (the highest) or
// random. Subnets and pools can set their own.
AddressOrder string `json:"address_order,omitempty"`
}
// Address orders: which never-used pool address a new client gets. When
// every free address has been used before, the one free the longest is
// given out, whatever the order.
const (
OrderSequential = "sequential"
OrderReverse = "reverse"
OrderRandom = "random"
)
// Pool orders: which of a subnet's pools a new address comes from.
const (
// PoolsListed tries the pools in the order they are listed.
PoolsListed = "listed"
// PoolsRandom picks a pool at random, weighted by its never-used
// addresses, so addresses spread over the whole subnet.
PoolsRandom = "random"
)
// AddressOrders are the address orders, for messages and help.
var AddressOrders = []string{OrderSequential, OrderReverse, OrderRandom}
// ValidAddressOrder reports whether s is an address order ("" too: the
// default or inherited one).
func ValidAddressOrder(s string) bool {
return s == "" || s == OrderSequential || s == OrderReverse || s == OrderRandom
}
// EffectiveAddressOrder is the settings' address order with its default.
func (s Settings) EffectiveAddressOrder() string {
if s.AddressOrder == "" {
return OrderSequential
}
return s.AddressOrder
}
// DefaultSettings are the settings of a new database.
@@ -101,9 +140,21 @@ type Subnet struct {
Interface string `json:"interface"`
Prefix netip.Prefix `json:"prefix"`
Pools []Pool `json:"pools"`
// PoolOrder is which pool a new address comes from: listed (the
// default, "") or random.
PoolOrder string `json:"pool_order,omitempty"`
// AddressOrder overrides the settings' address order inside the
// subnet's pools ("": inherit); a pool can override it in turn.
AddressOrder string `json:"address_order,omitempty"`
// 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"`
@@ -122,9 +173,25 @@ func (s *Subnet) Label() string {
type Pool struct {
Start netip.Addr `json:"start"`
End netip.Addr `json:"end"`
// Order overrides the subnet's address order in this pool ("":
// inherit).
Order string `json:"order,omitempty"`
}
func (p Pool) String() string { return p.Start.String() + "-" + p.End.String() }
// String is the pool as ParsePool reads it: FIRST-LAST, with :ORDER when
// it has its own.
func (p Pool) String() string {
if p.Order != "" {
return p.Range() + ":" + p.Order
}
return p.Range()
}
// Range is FIRST-LAST.
func (p Pool) Range() string { return p.Start.String() + "-" + p.End.String() }
// SameRange reports whether two pools cover the same addresses.
func (p Pool) SameRange(o Pool) bool { return p.Start == o.Start && p.End == o.End }
// Contains reports whether a is in the pool.
func (p Pool) Contains(a netip.Addr) bool {
@@ -140,9 +207,21 @@ func (p Pool) Size() int {
}
// ParsePool reads "10.0.10.100-10.0.10.199" (spaces around the dash are
// fine) or a prefix, "10.0.10.128/25".
// fine) or a prefix, "10.0.10.128/25", optionally followed by the pool's
// own address order: "10.0.10.100-10.0.10.199:random".
func ParsePool(s string) (Pool, error) {
s = strings.TrimSpace(s)
if r, order, ok := strings.Cut(s, ":"); ok {
order = strings.ToLower(strings.TrimSpace(order))
if order == "default" {
order = "" // the subnet's
} else if order == "" || !ValidAddressOrder(order) {
return Pool{}, fmt.Errorf("pool %q: the order after the colon is %s or default (the subnet's)", s, strings.Join(AddressOrders, ", "))
}
p, err := ParsePool(r)
p.Order = order
return p, err
}
if a, b, ok := strings.Cut(s, "-"); ok {
start, err1 := netip.ParseAddr(strings.TrimSpace(a))
end, err2 := netip.ParseAddr(strings.TrimSpace(b))
@@ -188,13 +267,17 @@ func Mask(p netip.Prefix) netip.Addr {
// MAC address, or by its client identifier (option 61) when ClientID is
// set. The subnet is the one whose prefix holds the address.
type Reservation struct {
ID int64 `json:"id"`
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"`
+22
View File
@@ -0,0 +1,22 @@
package model
import "testing"
func TestParsePoolOrder(t *testing.T) {
for in, want := range map[string]string{
"10.0.0.1-10.0.0.50": "10.0.0.1-10.0.0.50",
"10.0.0.1 - 10.0.0.50:Random": "10.0.0.1-10.0.0.50:random",
"10.0.0.0/26:reverse": "10.0.0.0-10.0.0.63:reverse",
"10.0.0.1-10.0.0.50:default": "10.0.0.1-10.0.0.50",
} {
p, err := ParsePool(in)
if err != nil || p.String() != want {
t.Errorf("%q: %v %v", in, p, err)
}
}
for _, in := range []string{"10.0.0.1-10.0.0.50:", "10.0.0.1-10.0.0.50:lowest"} {
if _, err := ParsePool(in); err == nil {
t.Errorf("%q accepted", in)
}
}
}
+3
View File
@@ -34,6 +34,9 @@ type Lease struct {
// Interface is where the client was last seen.
Interface string `json:"interface"`
Updated time.Time `json:"updated"`
// LastSeen is when the client last answered a ping (nil: never).
// Clients that block pings stay online while their lease lasts.
LastSeen *time.Time `json:"last_seen,omitempty"`
}
// StateAt is the lease's state at a time (an active lease past its end
+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"}
}
+46 -1
View File
@@ -118,6 +118,9 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.
if in.NotAuthoritative != nil {
set.NotAuthoritative = *in.NotAuthoritative
}
if in.AddressOrder != nil {
set.AddressOrder = orderInput(*in.AddressOrder)
}
return nil
}, func(st *model.State) any { return st.Settings })
}
@@ -194,6 +197,16 @@ func (s *Server) getSubnet(w http.ResponseWriter, r *http.Request, p *auth.Princ
return nil
}
// orderInput reads an address or pool order: "default" is "" (inherited,
// or the default); validation checks the rest.
func orderInput(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "default" {
return ""
}
return s
}
// applySubnetInput sets the given fields.
func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
if in.Name != nil {
@@ -219,6 +232,32 @@ func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
sn.Pools = append(sn.Pools, pool)
}
}
if in.PoolOrder != nil {
sn.PoolOrder = orderInput(*in.PoolOrder)
}
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
}
@@ -421,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
}
@@ -572,6 +614,9 @@ func (s *Server) interfaces(w http.ResponseWriter, r *http.Request, p *auth.Prin
st := s.eng.State()
out := []api.HostInterface{}
for name, ps := range all {
if s.excludedInterface(name) {
continue
}
hi := api.HostInterface{Name: name, Addresses: []string{}, Subnets: []int64{}}
for _, a := range ps {
hi.Addresses = append(hi.Addresses, a.String())
@@ -585,7 +630,7 @@ func (s *Server) interfaces(w http.ResponseWriter, r *http.Request, p *auth.Prin
}
// Subnets may name interfaces that don't exist (yet).
for _, sn := range st.Subnets {
if _, ok := all[sn.Interface]; !ok && sn.Interface != "" {
if _, ok := all[sn.Interface]; !ok && sn.Interface != "" && !s.excludedInterface(sn.Interface) {
found := false
for i := range out {
if out[i].Name == sn.Interface {
+92 -5
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 {
@@ -153,15 +196,59 @@ func (s *Server) listEvents(w http.ResponseWriter, r *http.Request, p *auth.Prin
if err := require(p, auth.ResLeases, false); err != nil {
return err
}
evs := s.dhcp.Events(queryInt(r, "limit", 200), r.URL.Query().Get("mac"), r.URL.Query().Get("interface"))
q := r.URL.Query()
var only *model.Subnet
if ref := q.Get("subnet"); ref != "" {
var err error
if only, err = subnetRef(s.eng.State(), ref); err != nil {
return err
}
}
mac, iface := q.Get("mac"), q.Get("interface")
evs := s.dhcp.Events(queryInt(r, "limit", 200), func(e dhcpd.Event) bool {
if mac != "" && !strings.EqualFold(e.MAC, mac) || iface != "" && e.Interface != iface {
return false
}
if only != nil {
// A message with an address belongs to the subnet that has
// it; one without (a DISCOVER) to every subnet on its
// interface.
if ip, err := netip.ParseAddr(e.IP); err == nil {
return only.Prefix.Contains(ip)
}
return e.Interface == only.Interface
}
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))
+15
View File
@@ -12,6 +12,7 @@ import (
"net/http"
"net/netip"
"os"
"path"
"strconv"
"strings"
"sync"
@@ -44,6 +45,20 @@ type Config struct {
// ACME keeps the certificate stewardd obtains itself (tls.acme; nil
// when off).
ACME *certs.Manager
// ExcludeInterfaces are interface names or path.Match patterns left
// out of GET /interfaces (interfaces.exclude).
ExcludeInterfaces []string
}
// excludedInterface reports whether interfaces.exclude hides the
// interface name.
func (s *Server) excludedInterface(name string) bool {
for _, p := range s.cfg.ExcludeInterfaces {
if ok, _ := path.Match(p, name); ok {
return true
}
}
return false
}
// Interfaces lists the host's interfaces.
+151
View File
@@ -5,12 +5,14 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"testing"
@@ -246,6 +248,38 @@ 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} {
if code := e.call(e.token, "GET", "/events?subnet="+url.QueryEscape(ref), nil, &evs); code != 200 || len(evs) != want {
t.Errorf("events of subnet %s: %d %+v", ref, code, evs)
}
}
if code := e.call(e.token, "GET", "/events?subnet=nope", nil, nil); code != 404 && code != 400 {
t.Errorf("unknown subnet: %d", code)
}
if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 204 {
t.Errorf("delete lease: %d", code)
}
@@ -380,3 +414,120 @@ func TestTokenRotation(t *testing.T) {
t.Errorf("a token rotating itself: %d", code)
}
}
func TestInterfacesExclude(t *testing.T) {
e := newEnv(t)
e.srv.cfg.ExcludeInterfaces = []string{"eth0.*", "docker*"}
// A subnet on an excluded interface the host doesn't have.
if c := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("172.17.0.0/16"), Interface: ptr("docker0")}, nil); c != 201 {
t.Fatalf("create: %d", c)
}
var ifs []api.HostInterface
e.call(e.token, "GET", "/interfaces", nil, &ifs)
if len(ifs) != 1 || ifs[0].Name != "eth0" {
t.Errorf("interfaces: %+v", ifs)
}
}
func TestAddressOrderAPI(t *testing.T) {
e := newEnv(t)
var mr api.MutationResponse
in := api.SubnetInput{Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), PoolOrder: ptr("random"), AddressOrder: ptr("Reverse"),
Pools: &[]string{"10.0.20.100-10.0.20.110:random", "10.0.20.10-10.0.20.20"}}
if code := e.call(e.token, "POST", "/subnets", in, &mr); code != 201 {
t.Fatalf("create: %d %+v", code, mr)
}
sn := e.eng.State().Subnets[0]
// The pools keep the order given.
if sn.PoolOrder != "random" || sn.AddressOrder != "reverse" || sn.Pools[0].String() != "10.0.20.100-10.0.20.110:random" || sn.Pools[1].Order != "" {
t.Errorf("subnet %+v", sn)
}
// "listed" and "default" are the defaults, stored as "".
if code := e.call(e.token, "PATCH", "/subnets/"+itoa(sn.ID), api.SubnetInput{PoolOrder: ptr("listed"), AddressOrder: ptr("default")}, nil); code != 200 {
t.Errorf("patch: %d", code)
}
if sn = e.eng.State().Subnets[0]; sn.PoolOrder != "" || sn.AddressOrder != "" {
t.Errorf("defaults %+v", sn)
}
var eb api.ErrorBody
if code := e.call(e.token, "PATCH", "/settings", api.SettingsInput{AddressOrder: ptr("lowest")}, &eb); code != 422 || eb.Error.Problems[0].Code != "address_order" {
t.Errorf("bad order: %d %+v", code, eb)
}
if code := e.call(e.token, "PATCH", "/subnets/"+itoa(sn.ID), api.SubnetInput{Pools: &[]string{"10.0.20.10-10.0.20.20:lowest"}}, nil); code != 400 {
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)
}
}
+24 -5
View File
@@ -2,13 +2,14 @@ package store
import (
"context"
"database/sql"
"net/netip"
"time"
"steward/internal/model"
)
const leaseCols = `ip, subnet_id, mac, client_id, hostname, state, start, end, interface, updated`
const leaseCols = `ip, subnet_id, mac, client_id, hostname, state, start, end, interface, updated, last_seen`
// LoadLeases reads every lease.
func (s *Store) LoadLeases(ctx context.Context) ([]*model.Lease, error) {
@@ -21,11 +22,13 @@ func (s *Store) LoadLeases(ctx context.Context) ([]*model.Lease, error) {
for rows.Next() {
l := &model.Lease{}
var ip, start, end, updated string
if err := rows.Scan(&ip, &l.SubnetID, &l.MAC, &l.ClientID, &l.Hostname, &l.State, &start, &end, &l.Interface, &updated); err != nil {
var seen sql.NullString
if err := rows.Scan(&ip, &l.SubnetID, &l.MAC, &l.ClientID, &l.Hostname, &l.State, &start, &end, &l.Interface, &updated, &seen); err != nil {
return nil, err
}
l.IP, _ = netip.ParseAddr(ip)
l.Start, l.End, l.Updated = parseTS(start), parseTS(end), parseTS(updated)
l.LastSeen = parseTSPtr(seen)
out = append(out, l)
}
return out, rows.Err()
@@ -46,10 +49,26 @@ func (s *Store) PutLeases(ctx context.Context, ls []*model.Lease) error {
}
defer tx.Rollback()
for _, l := range ls {
if _, err := tx.ExecContext(ctx, `INSERT INTO leases (`+leaseCols+`) VALUES (?,?,?,?,?,?,?,?,?,?)
if _, err := tx.ExecContext(ctx, `INSERT INTO leases (`+leaseCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(ip) DO UPDATE SET subnet_id = excluded.subnet_id, mac = excluded.mac, client_id = excluded.client_id, hostname = excluded.hostname,
state = excluded.state, start = excluded.start, end = excluded.end, interface = excluded.interface, updated = excluded.updated`,
l.IP.String(), l.SubnetID, l.MAC, l.ClientID, l.Hostname, l.State, ts(l.Start), ts(l.End), l.Interface, ts(l.Updated)); err != nil {
state = excluded.state, start = excluded.start, end = excluded.end, interface = excluded.interface, updated = excluded.updated, last_seen = excluded.last_seen`,
l.IP.String(), l.SubnetID, l.MAC, l.ClientID, l.Hostname, l.State, ts(l.Start), ts(l.End), l.Interface, ts(l.Updated), tsPtr(l.LastSeen)); err != nil {
return err
}
}
return tx.Commit()
}
// SetLastSeen records when leases' clients answered a ping, in one
// transaction.
func (s *Store) SetLastSeen(ctx context.Context, seen map[netip.Addr]time.Time) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for ip, t := range seen {
if _, err := tx.ExecContext(ctx, `UPDATE leases SET last_seen = ? WHERE ip = ?`, ts(t), ip.String()); err != nil {
return err
}
}
+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, 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.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, 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,
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.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{}
+19
View File
@@ -197,6 +197,25 @@ ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
// 3: disabled reservations.
`
ALTER TABLE reservations ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
`,
// 4: when a lease's client last answered a ping.
`
ALTER TABLE leases ADD COLUMN last_seen TEXT;
`,
// 5: the order addresses are handed out in (a pool's own is in pools).
`
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 '[]';
`,
}
+67 -13
View File
@@ -84,6 +84,9 @@ func (c *checker) settings(s model.Settings) {
if s.DeclineHold != 0 && (s.DeclineHold < 60 || s.DeclineHold > 30*24*3600) {
c.add(Error, "decline_hold_range", "settings", "the decline hold is %d seconds; use 1 minute to 30 days", s.DeclineHold)
}
if !model.ValidAddressOrder(s.AddressOrder) {
c.add(Error, "address_order", "settings", "the address order %q is not one of %s", s.AddressOrder, strings.Join(model.AddressOrders, ", "))
}
c.options(s.Options, "settings")
}
@@ -161,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 != "" {
@@ -173,27 +177,70 @@ func (c *checker) pools(s *model.Subnet, obj string) {
if len(s.Pools) == 0 && !s.Disabled {
c.add(Warning, "subnet_no_pools", obj, "the subnet has no pools: only clients with a reservation get an address")
}
if s.PoolOrder != "" && s.PoolOrder != model.PoolsListed && s.PoolOrder != model.PoolsRandom {
c.add(Error, "pool_order", obj, "the pool order %q is not listed or random", s.PoolOrder)
}
if !model.ValidAddressOrder(s.AddressOrder) {
c.add(Error, "address_order", obj, "the address order %q is not one of %s", s.AddressOrder, strings.Join(model.AddressOrders, ", "))
}
network, broadcast := s.Prefix.Addr(), model.Last(s.Prefix)
for i, p := range s.Pools {
if !model.ValidAddressOrder(p.Order) {
c.add(Error, "address_order", obj, "pool %s: the address order %q is not one of %s", p.Range(), p.Order, strings.Join(model.AddressOrders, ", "))
}
if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) {
c.add(Error, "pool_invalid", obj, "pool %s is not a range of IPv4 addresses", p)
c.add(Error, "pool_invalid", obj, "pool %s is not a range of IPv4 addresses", p.Range())
continue
}
if !s.Prefix.Contains(p.Start) || !s.Prefix.Contains(p.End) {
c.add(Error, "pool_outside_subnet", obj, "pool %s is not inside %s", p, s.Prefix)
c.add(Error, "pool_outside_subnet", obj, "pool %s is not inside %s", p.Range(), s.Prefix)
continue
}
if p.Contains(network) || p.Contains(broadcast) {
c.add(Error, "pool_network_address", obj, "pool %s includes the network address %s or the broadcast address %s; start it at %s and end it at %s at most", p, network, broadcast, network.Next(), broadcast.Prev())
c.add(Error, "pool_network_address", obj, "pool %s includes the network address %s or the broadcast address %s; start it at %s and end it at %s at most", p.Range(), network, broadcast, network.Next(), broadcast.Prev())
}
for _, o := range s.Pools[:i] {
if o.Start.Is4() && o.End.Is4() && !(p.End.Less(o.Start) || o.End.Less(p.Start)) {
c.add(Error, "pool_overlap", obj, "pools %s and %s overlap", o, p)
c.add(Error, "pool_overlap", obj, "pools %s and %s overlap", o.Range(), p.Range())
}
}
}
}
// 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 := ""
@@ -221,7 +268,7 @@ func (c *checker) routers(s *model.Subnet, global []model.OptionValue, obj strin
}
for _, p := range s.Pools {
if p.Contains(a) {
c.add(Warning, "router_in_pool", obj, "the router %s is inside pool %s; it is never handed out while it answers pings, but take it out of the pool", a, p)
c.add(Warning, "router_in_pool", obj, "the router %s is inside pool %s; it is never handed out while it answers pings, but take it out of the pool", a, p.Range())
}
}
}
@@ -242,7 +289,7 @@ func (c *checker) hostInterface(s *model.Subnet, env *Env, obj string) {
if s.Prefix.Contains(a.Addr()) {
for _, p := range s.Pools {
if p.Contains(a.Addr()) {
c.add(Warning, "pool_includes_server", obj, "pool %s includes this server's own address %s; it is never handed out, but take it out of the pool", p, a.Addr())
c.add(Warning, "pool_includes_server", obj, "pool %s includes this server's own address %s; it is never handed out, but take it out of the pool", p.Range(), a.Addr())
}
}
return
@@ -282,14 +329,21 @@ 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 && 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 o := byClient[k]; o != nil {
c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label())
}
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)
+50 -9
View File
@@ -1,22 +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 = await get("/interfaces");
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(), interface: iface.value, limit: 300 });
const evs = await get("/events", { mac: mac.value.trim(), subnet: subnetSel.value, interface: iface.value, limit: 300 });
mount(out, evs.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
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)),
@@ -25,15 +55,26 @@ 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("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || iface.value ? " that match." : ".")));
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);
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Activity"),
h("div", { class: "muted" }, "The latest DHCP messages Steward received and sent, newest first. A MAC address leads to that client's leases; Only this follows just that client."))),
h("div", { class: "filter-row" }, mac, iface, pause),
h("div", { class: "filter-row" }, mac, subnetSel, iface, pause),
h("div", { class: "panel" }, out));
await draw();
const t = setInterval(() => { if (live) draw().catch(() => {}); }, 3000);
+13 -5
View File
@@ -16,14 +16,16 @@ 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) },
{ label: "Last seen", render: (l) => l.last_seen ? time(l.last_seen) : "" },
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." });
@@ -34,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,18 +1,20 @@
// Reservations: fixed addresses for clients.
import { h, mount, table, input, modal, errorBox, field, confirmDialog, select, debounce, badge, checkbox } from "../ui.js";
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.
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
? h("span", { title: "lease until " + new Date(r.lease.end).toLocaleString() }, badge("online", "ok"), h("span", { class: "muted small" }, " on " + r.lease.interface))
? h("span", { title: "lease until " + new Date(r.lease.end).toLocaleString() }, badge("online", "ok"), h("span", { class: "muted small" }, " on " + r.lease.interface),
r.lease.last_seen ? h("span", { class: "muted small", title: "last answered a ping" }, ", seen ", time(r.lease.last_seen)) : null)
: badge("offline") },
{ label: "Options", render: (r) => r.options.length ? h("span", { class: "mono small" }, r.options.map((o) => o.name + "=" + o.value).join("; ")) : "" },
{ label: "Comment", render: (r) => r.comment },
@@ -49,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 : "" });
@@ -58,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),
@@ -67,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);
@@ -86,19 +92,25 @@ export async function render(el, params, query) {
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
const search = input({ type: "search", placeholder: "address, MAC or host name" });
const out = h("div");
let gen = 0;
const draw = async () => {
const my = ++gen;
const q = search.value.trim().toLowerCase();
const rs = (await get("/reservations", { subnet: subnetSel.value })).filter((r) =>
!q || r.ip.includes(q) || (r.mac || "").includes(q) || (r.hostname || "").toLowerCase().includes(q) || (r.client_id || "").includes(q));
if (my !== gen) return; // a newer draw (typing, a change) won
mount(out, reservationsTable(rs, draw, { empty: q || subnetSel.value ? "None match." : "No reservations yet." }));
};
subnetSel.addEventListener("change", draw);
search.addEventListener("input", debounce(draw, 200));
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Reservations"), h("div", { class: "muted" }, "Fixed addresses for clients, by MAC address or client identifier.")),
h("div", { class: "grow" }, h("h1", null, "Reservations"), h("div", { class: "muted" }, "Fixed addresses for clients, by MAC address or client identifier. Refreshed every 15 seconds.")),
can("config", true) ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => reservationDialog(null, draw) }, "New reservation")) : null),
h("div", { class: "filter-row" }, subnetSel, search),
h("div", { class: "panel" }, out));
await draw();
// Online and last seen change as clients come and go.
const t = setInterval(() => { if (!document.hidden) draw().catch(() => {}); }, 15000);
onLeave(() => clearInterval(t));
}
+6 -4
View File
@@ -1,19 +1,20 @@
// Settings: the default lease time, conflict checks, authority, and the
// options every subnet gets.
import { h, mount, input, field, checkbox, errorBox, readOnly } from "../ui.js";
import { h, mount, input, field, checkbox, errorBox, readOnly, select } from "../ui.js";
import { get, change, can } from "../api.js";
import { optionsEditor, fmtSeconds, parseSeconds } from "../widgets.js";
import { optionsEditor, fmtSeconds, parseSeconds, ADDRESS_ORDERS } from "../widgets.js";
import { rerender } from "../app.js";
export async function render(el) {
const s = await get("/settings");
const write = can("config", true);
const lease = input({ value: fmtSeconds(s.lease_time || 43200) });
const ping = checkbox("Ping an address before offering it, and skip it if something answers", s.ping_check);
const ping = checkbox("Ping an address before offering it, and skip it if something answers; also ping clients every minute for their last-seen time", s.ping_check);
const timeout = input({ type: "number", min: 50, max: 5000, value: s.ping_timeout_ms || 500 });
timeout.style.width = "7em";
const hold = input({ value: fmtSeconds(s.decline_hold || 86400) });
const auth = checkbox("Authoritative: refuse (NAK) requests for addresses that don't belong on the interface", !s.not_authoritative);
const order = select(ADDRESS_ORDERS, s.address_order || "sequential");
const opts = await optionsEditor(s.options);
const err = h("div");
const save = async (dry) => {
@@ -22,7 +23,7 @@ export async function render(el) {
if (lt === null || dh === null) { mount(err, h("div", { class: "msg err" }, "Write times as 12h, 1d or 30m.")); return; }
try {
const r = await change("PATCH", "/settings", { lease_time: lt, ping_check: ping.input.checked, ping_timeout_ms: parseInt(timeout.value, 10) || 0,
decline_hold: dh, not_authoritative: !auth.input.checked, options: opts.value() }, { query: dry ? { dry_run: 1 } : undefined });
decline_hold: dh, not_authoritative: !auth.input.checked, address_order: order.value, options: opts.value() }, { query: dry ? { dry_run: 1 } : undefined });
if (!dry && r.applied) rerender();
} catch (e) { mount(err, errorBox(e)); }
};
@@ -35,6 +36,7 @@ export async function render(el) {
field("Default lease time", lease, "How long clients keep an address unless a subnet says otherwise (12h, 1d). They renew halfway."),
field("Options for every subnet", opts, "Usually domain-name-servers and domain-name; routers belong to each subnet."),
field("Conflict check", h("div", null, ping, h("div", { class: "small" }, "wait ", timeout, " ms for an answer"))),
field("Address order", order, "Which address of a pool a new client gets: sequential (the lowest), reverse (the highest) or random. Only addresses never handed out count; when there are none left, the one free the longest. Subnets and pools can set their own."),
field("Hold back for", hold, "How long an address a client declined (or one that answered a ping) isn't handed out."),
field("Authority", auth, "Turn this off only if another DHCP server serves the same network.")),
err,
+57 -5
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 } from "../widgets.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;
@@ -19,8 +19,10 @@ export async function render(el, params) {
h("div", { class: "grow" },
h("h1", null, subnetLabel(s), " ", s.disabled ? badge("disabled", "off") : null),
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
s.pools.length ? h("span", { class: "mono" }, s.pools.map((p) => p.start + "-" + p.end).join(", ")) : "none",
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
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",
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",
@@ -30,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 {
+2 -2
View File
@@ -1,12 +1,12 @@
// The subnets, and the New subnet dialog.
import { h, mount, table, modal, errorBox, badge } from "../ui.js";
import { get, change, can } from "../api.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText } from "../widgets.js";
import { toggleSubnet } from "./subnet.js";
export async function render(el, params, query) {
const subnets = await get("/subnets");
const poolsText = (s) => s.pools.length ? s.pools.map((p) => p.start + "-" + p.end).join(", ") : "–";
const poolsText = (s) => s.pools.length ? s.pools.map(poolText).join(", ") : "–";
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Subnets"),
+8
View File
@@ -342,9 +342,15 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
.pool-rows { display: flex; flex-direction: column; gap: 6px; }
.pool-row { display: flex; gap: 6px; align-items: center; }
.pool-row input { font-family: var(--mono); width: 18em; }
.pool-row select { width: auto; }
/* 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); }
@@ -354,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; }
+42 -12
View File
@@ -114,25 +114,51 @@ const examples = {
// ---- pools
// poolsEditor edits a subnet's pools; value() is ["first-last", ...].
// Each pool has its own address order, or the subnet's; ↑ moves a pool up
// (with pools in the order listed, the first is used first).
export function poolsEditor(pools, placeholder) {
const rows = h("div", { class: "pool-rows" });
const box = h("div", null, rows);
const list = [];
const addRow = (v) => {
const inp = input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" });
const row = h("div", { class: "pool-row" }, inp,
h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { row.remove(); list.splice(list.indexOf(inp), 1); } }, "✕"));
list.push(inp);
rows.append(row);
return inp;
let list = [];
const addRow = (v, order) => {
const it = { inp: input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" }) };
it.sel = select([["", "subnet's order"], ...ADDRESS_ORDERS.map((o) => [o, o])], order || "", { title: "The order addresses of this pool are handed out in" });
it.row = h("div", { class: "pool-row" }, it.inp, it.sel,
h("button", { type: "button", class: "small", title: "Move this pool up: it is used before the ones below", onclick: () => {
const i = list.indexOf(it);
if (i > 0) { list.splice(i, 1); list.splice(i - 1, 0, it); rows.insertBefore(it.row, list[i].row); }
} }, "↑"),
h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { it.row.remove(); list = list.filter((x) => x !== it); } }, "✕"));
list.push(it);
rows.append(it.row);
return it.inp;
};
for (const p of pools || []) addRow(p.start + "-" + p.end);
for (const p of pools || []) addRow(p.start + "-" + p.end, p.order);
box.append(h("button", { type: "button", class: "small", onclick: () => addRow("").focus() }, "+ Add pool"));
box.value = () => list.map((i) => i.value.trim()).filter((s) => s !== "");
box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].value) list[0].value = v; };
box.value = () => list.map((it) => {
const v = it.inp.value.trim();
return v && it.sel.value && !v.includes(":") ? v + ":" + it.sel.value : v;
}).filter((s) => s !== "");
box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].inp.value) list[0].inp.value = v; };
return box;
}
export const ADDRESS_ORDERS = ["sequential", "reverse", "random"];
// poolText shows a pool, with its own address order.
export function poolText(p) {
return p.start + "-" + p.end + (p.order ? " (" + p.order + ")" : "");
}
// orderText says how a subnet hands out new addresses, or "" for the
// defaults.
export function orderText(s) {
const parts = [];
if (s.pool_order === "random") parts.push("a pool at random");
if (s.address_order) parts.push(s.address_order + " addresses");
return parts.join(", ");
}
// ---- the subnet form
// v4 helpers for suggestions.
@@ -163,6 +189,8 @@ export async function subnetForm(sn, settings) {
const ifaceOther = input({ value: sn && !ifaceNames.includes(sn.interface) ? sn.interface : "", placeholder: "eth0.30 (one that comes up later)" });
const prefix = input({ value: sn ? sn.prefix : "", placeholder: "10.0.10.0/24", class: "mono" });
const pools = poolsEditor(sn ? sn.pools : []);
const poolOrder = select([["", "in the order listed"], ["random", "at random (weighted by free addresses)"]], sn ? sn.pool_order || "" : "");
const addrOrder = select([["", "the settings' (" + (settings.address_order || "sequential") + ")"], ...ADDRESS_ORDERS.map((o) => [o, o])], sn ? sn.address_order || "" : "");
const lease = input({ value: sn ? fmtSeconds(sn.lease_time) : "", placeholder: "the default: " + fmtSeconds(settings.lease_time || 43200) });
const opts = await optionsEditor(sn ? sn.options : [], settings.options);
const comment = input({ value: sn ? sn.comment : "" });
@@ -192,6 +220,8 @@ export async function subnetForm(sn, settings) {
field("Interface", h("div", null, ifaceSel, " ", ifaceOther)), ifaceHint,
field("Network", prefix, "The subnet's prefix; the mask clients get comes from it."),
field("Pools", pools, "Ranges handed out to any client: FIRST-LAST, or a prefix. Reserved addresses inside a pool are never handed out to others."),
field("Pool order", poolOrder, "Which pool a new client's address comes from."),
field("Address order", addrOrder, "Which address of a pool: sequential (the lowest), reverse (the highest) or random. A pool can set its own. New clients get an address never handed out before; when there are none, the one free the longest. Returning clients keep theirs."),
field("Lease time", lease, "12h, 1d, 30m; empty for the default."),
field("Options", opts),
field("Comment", comment),
@@ -199,7 +229,7 @@ export async function subnetForm(sn, settings) {
grid.values = () => {
const lt = parseSeconds(lease.value);
if (lt === null) throw new Error("Lease time: write it as 12h, 1d or 30m");
const v = { name: name.value.trim(), interface: ifaceValue(), prefix: prefix.value.trim(), pools: pools.value(), lease_time: lt, options: opts.value(), comment: comment.value };
const v = { name: name.value.trim(), interface: ifaceValue(), prefix: prefix.value.trim(), pools: pools.value(), pool_order: poolOrder.value || "listed", address_order: addrOrder.value || "default", lease_time: lt, options: opts.value(), comment: comment.value };
if (sn) v.disabled = disabled.input.checked;
return v;
};
+59
View File
@@ -178,12 +178,24 @@ run "undo the lease time change" 0 S history undo $(S history list -o json | gre
run "lease time back" 0 S subnet show iot
contains "Lease time: *1h"
# ---- last seen: a client that stays up answers pings
# busybox's default script puts the leased address on the interface.
docker run -d --name ${P}-live --network ${P}-b --mac-address 02:00:00:00:00:30 --cap-add NET_ADMIN steward-e2e-client \
udhcpc -i eth0 -f -x hostname:live >/dev/null
for i in $(seq 1 20); do S lease list | grep -q " live " && break; sleep 0.5; done
# ---- restart: leases survive
docker exec ${P}-srv sh -c 'kill $(pidof stewardd)'; sleep 1
run "restart stewardd" 0 start
run "leases after the restart" 0 S lease list
contains "$NA.101 *02:00:00:00:00:01 *laptop *active"
# A ping round runs at start (then every minute).
sleep 2
run "last seen" 0 S lease list
contains "live *active *in [0-9hm]* *now *eth1"
contains "laptop *active *in [0-9hm]* *- *eth0"
run "the client still gets its address" 0 udhcpc a 02:00:00:00:00:01
contains "BOUND ip=$NA.101 "
@@ -208,6 +220,53 @@ run "delete a lease" 0 S lease delete $NA.101 --yes
run "audit log" 0 S audit --action lease.
contains "lease.delete"
# ---- address order
run "reverse address order" 0 S subnet update iot --address-order reverse
run "the subnet shows its order" 0 S subnet show iot
contains "addresses reverse"
run "a new client gets the highest never-used address" 0 udhcpc b 02:00:00:00:00:40
contains "BOUND ip=$NB.110 "
run "a pool's own order wins" 0 S subnet update iot --add-pool $NB.100-$NB.110:sequential
run "the pool shows it" 0 S subnet show iot
contains "$NB.100-$NB.110:sequential"
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