Author SHA1 Message Date
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
48 changed files with 1026 additions and 121 deletions

No files matched your search

+19
View File
@@ -105,6 +105,25 @@ 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.
- **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.
+4 -1
View File
@@ -107,7 +107,10 @@ sudo systemctl disable --now kea-dhcp4-server
## Status
Steward 0.1.0 is in development.
Steward 0.1.0 is in testing (0.1.0-rc.1): a DHCPv4 server with subnets per
interface, reservations, a web UI, REST API and CLI, Kea import, the web
UI's certificate through Jarl, clients' last-seen times and a choice of
address order per subnet and pool.
## Licence
+1 -1
View File
@@ -1 +1 @@
0.1.0-dev
0.1.0-rc.1
+15 -6
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)
}
@@ -157,7 +160,7 @@ then on. The host name the client sent becomes the reservation's, unless
}
func activityCmd() *cobra.Command {
var mac, iface string
var mac, subnet, iface string
var limit int
var follow bool
cmd := &cobra.Command{
@@ -193,7 +196,7 @@ func activityCmd() *cobra.Command {
}
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)), &evs); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
@@ -212,6 +215,7 @@ 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")
@@ -245,10 +249,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 +317,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 +344,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)
+8
View File
@@ -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 "-"
+67 -10
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,7 @@ 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, "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 +111,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 +123,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 +153,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 +176,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 +188,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 +220,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 +251,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 +261,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 +301,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")
@@ -339,6 +390,9 @@ func reservationCmd() *cobra.Command {
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 {
@@ -374,7 +428,10 @@ func reservationCmd() *cobra.Command {
fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname))
fmt.Fprintln(w, "Options:\t"+optionsText(r.Options))
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")
}
+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)
+16 -7
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,12 +94,17 @@ 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'.
`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
@@ -109,11 +118,11 @@ settings'.
| | |
|---|---|
| `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) |
| `GET /events[?mac=&subnet=&interface=&limit=]` | the latest DHCP messages, newest first (the last 1000 are kept in memory). `subnet` (ID, name or prefix) keeps messages for its addresses, and those without an address on its interface |
### History and import
+10 -8
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,7 +75,7 @@ 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]` | |
@@ -93,9 +93,11 @@ one. An option Steward doesn't know: `--option 224:string=hello`.
### 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] [-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
@@ -113,9 +115,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) |
+51 -3
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
@@ -119,8 +121,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 +134,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,6 +180,17 @@ 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.
+2
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 |
+15 -9
View File
@@ -68,19 +68,25 @@ 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"`
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).
+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")
+14 -17
View File
@@ -156,29 +156,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() {
@@ -290,7 +285,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
}
@@ -300,7 +297,7 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa
}
}
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(sub.leaseTime), Interface: iface, LastSeen: seen}
err := s.putLease(ctx, l)
delete(s.offers, ip)
s.mu.Unlock()
+119
View File
@@ -461,3 +461,122 @@ 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)
}
}
+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)
}
}
+11 -7
View File
@@ -7,10 +7,10 @@ import (
"context"
"errors"
"log/slog"
"math/rand/v2"
"net"
"net/netip"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
@@ -24,6 +24,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 +33,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 +53,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 +85,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
@@ -342,15 +346,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)
+7 -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,10 @@ 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 = ""
}
}
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)
}
}
}
+75 -2
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,6 +140,12 @@ 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"`
@@ -122,9 +167,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 +201,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))
+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
+23 -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,12 @@ 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.LeaseTime != nil {
sn.LeaseTime = *in.LeaseTime
}
@@ -572,6 +591,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 +607,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 {
+24 -1
View File
@@ -153,7 +153,30 @@ 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
})
out := make([]api.Event, len(evs))
for i, e := range evs {
out[i] = api.Event(e)
+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.
+54
View File
@@ -11,6 +11,7 @@ import (
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"testing"
@@ -246,6 +247,16 @@ func TestLeasesThroughDHCP(t *testing.T) {
if len(evs) != 4 || evs[0].Type != "DHCPACK" {
t.Errorf("events %+v", evs)
}
// 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 +391,46 @@ 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)
}
}
+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
}
}
+5 -5
View File
@@ -25,7 +25,7 @@ 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, disabled, comment FROM subnets ORDER BY id`)
if err != nil {
return nil, err
}
@@ -34,7 +34,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
sn := &model.Subnet{}
var prefix, pools, opts 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, &disabled, &sn.Comment); err != nil {
return nil, err
}
sn.Prefix, _ = netip.ParsePrefix(prefix)
@@ -146,10 +146,10 @@ 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, 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, disabled = excluded.disabled, comment = excluded.comment`,
sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.PoolOrder, sn.AddressOrder, sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment)
return err
case model.KindReservation:
if c.After == nil {
+9
View File
@@ -197,6 +197,15 @@ 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 '';
`,
}
+18 -6
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")
}
@@ -173,22 +176,31 @@ 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())
}
}
}
@@ -221,7 +233,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 +254,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
+7 -4
View File
@@ -2,16 +2,18 @@
import { h, mount, input, select, debounce } from "../ui.js";
import { get } from "../api.js";
import { onLeave } from "../app.js";
import { subnetLabel } from "../widgets.js";
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");
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("tbody", null, evs.map((e) => {
@@ -26,14 +28,15 @@ export async function render(el, params, query) {
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("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || subnetSel.value || iface.value ? " that match." : ".")));
};
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);
@@ -20,6 +20,7 @@ export function leasesTable(leases, done, opts = {}) {
{ 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) => [
@@ -1,7 +1,8 @@
// 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 { onLeave } from "../app.js";
// reservationsTable lists reservations with Edit and Delete.
export function reservationsTable(rs, done, opts = {}) {
@@ -12,7 +13,8 @@ export function reservationsTable(rs, done, opts = {}) {
{ 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 },
@@ -86,19 +88,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,
+3 -2
View File
@@ -1,7 +1,7 @@
// 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 { 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";
@@ -19,7 +19,8 @@ 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",
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")),
h("div", { class: "subnet-usage" }, usageBar(s.usage)),
can("config", true) ? h("div", { class: "actions" }, h("button", {
+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"),
+1
View File
@@ -342,6 +342,7 @@ 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); }
+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;
};
+26
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,20 @@ 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
# ---- backup
run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup