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 1018 additions and 113 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 - **Disabled reservations** (`steward reservation disable|enable`, Disable
in the web UI): kept but ignored, so the client gets a pool address like 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. 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`, - **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`,
Rotate in the web UI): a new secret for a token, with an optional grace Rotate in the web UI): a new secret for a token, with an optional grace
period for the old one, as in Jarl. period for the old one, as in Jarl.
+4 -1
View File
@@ -107,7 +107,10 @@ sudo systemctl disable --now kea-dhcp4-server
## Status ## 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 ## 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 ls = keep
} }
return output(ls, func(w io.Writer) { 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 { for _, l := range ls {
st := l.State st := l.State
if l.Reservation != 0 { if l.Reservation != 0 {
st += ", reserved" 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, "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, "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) 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 { if l.Reservation != 0 {
fmt.Fprintf(w, "Reservation:\t%d\n", l.Reservation) 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 { func activityCmd() *cobra.Command {
var mac, iface string var mac, subnet, iface string
var limit int var limit int
var follow bool var follow bool
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -193,7 +196,7 @@ func activityCmd() *cobra.Command {
} }
for { for {
var evs []api.Event 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 return err
} }
if flagOutput == "json" || flagOutput == "yaml" { if flagOutput == "json" || flagOutput == "yaml" {
@@ -212,6 +215,7 @@ func activityCmd() *cobra.Command {
} }
f := cmd.Flags() f := cmd.Flags()
f.StringVar(&mac, "mac", "", "only this client's") 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.StringVar(&iface, "interface", "", "only this interface's")
f.IntVar(&limit, "limit", 50, "at most this many") f.IntVar(&limit, "limit", 50, "at most this many")
f.BoolVarP(&follow, "follow", "f", false, "keep watching") f.BoolVarP(&follow, "follow", "f", false, "keep watching")
@@ -245,10 +249,11 @@ func settingsCmd() *cobra.Command {
auth = "no (such requests get no answer)" auth = "no (such requests get no answer)"
} }
fmt.Fprintf(w, "Authoritative:\t%s\n", auth) 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 var opts, unsetOpts []string
set := &cobra.Command{ set := &cobra.Command{
Use: "set", Use: "set",
@@ -312,6 +317,9 @@ reservation's own option of the same name wins.
not := !*a not := !*a
in.NotAuthoritative = &not in.NotAuthoritative = &not
} }
if fl.Changed("address-order") {
in.AddressOrder = &addrOrder
}
if len(opts) > 0 || len(unsetOpts) > 0 { if len(opts) > 0 || len(unsetOpts) > 0 {
var cur model.Settings var cur model.Settings
if err := c.Get(ctx(), "/settings", nil, &cur); err != nil { 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 := set.Flags()
f.StringVar(&leaseTime, "lease-time", "", "the default lease time (12h, 1d)") 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(&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(&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(&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(&opts, "option", nil, "set an option NAME=VALUE for every subnet")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option") f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
cmd.AddCommand(set) cmd.AddCommand(set)
+8
View File
@@ -424,6 +424,14 @@ func fmtWhen(t time.Time) string {
return "in " + s 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 { func orDash(s string) string {
if s == "" { if s == "" {
return "-" return "-"
+67 -10
View File
@@ -17,6 +17,20 @@ func subnetPath(ref string) string {
return "/subnets/" + url.PathEscape(strings.Replace(ref, "/", "_", 1)) 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 { func poolsText(ps []model.Pool) string {
var out []string var out []string
for _, p := range ps { for _, p := range ps {
@@ -78,6 +92,7 @@ func subnetCmd() *cobra.Command {
fmt.Fprintf(w, "Interface:\t%s\n", s.Interface) 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, "Prefix:\t%s (mask %s)\n", s.Prefix, model.Mask(s.Prefix))
fmt.Fprintf(w, "Pools:\t%s\n", poolsText(s.Pools)) fmt.Fprintf(w, "Pools:\t%s\n", poolsText(s.Pools))
fmt.Fprintf(w, "Order:\t%s\n", orderText(s.Subnet))
fmt.Fprintf(w, "Leased:\t%s, %d declined\n", usageText(s.Usage), s.Usage.Declined) fmt.Fprintf(w, "Leased:\t%s, %d declined\n", usageText(s.Usage), s.Usage.Declined)
fmt.Fprintf(w, "Reservations:\t%d (%d with a lease now)\n", s.Usage.Reservations, s.Usage.ReservationsLeased) fmt.Fprintf(w, "Reservations:\t%d (%d with a lease now)\n", s.Usage.Reservations, s.Usage.ReservationsLeased)
lt := "the default" lt := "the default"
@@ -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 var pools, opts, addPools, removePools, unsetOpts []string
add := &cobra.Command{ add := &cobra.Command{
Use: "add PREFIX --interface IFACE", Use: "add PREFIX --interface IFACE",
@@ -108,7 +123,11 @@ get its addresses.
--pool 10.0.10.100-10.0.10.199 \ --pool 10.0.10.100-10.0.10.199 \
--option routers=10.0.10.1 --option domain-name-servers=10.0.10.2 --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 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.`, other option, with its type for ones Steward doesn't know: 224:string=hi.`,
Args: cobra.ExactArgs(1), 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 in.LeaseTime = &n
} }
if poolOrder != "" {
in.PoolOrder = &poolOrder
}
if addrOrder != "" {
in.AddressOrder = &addrOrder
}
if len(opts) > 0 { if len(opts) > 0 {
o, err := optionsArg(opts) o, err := optionsArg(opts)
if err != nil { 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 := add.Flags()
f.StringVar(&iface, "interface", "", "the network interface it is served on (required)") f.StringVar(&iface, "interface", "", "the network interface it is served on (required)")
f.StringVar(&name, "name", "", "a name for it") 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.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d; default: the settings')")
f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE (repeat for several)") f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE (repeat for several)")
f.StringVar(&comment, "comment", "", "a comment") 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{ update := &cobra.Command{
Use: "update SUBNET", Use: "update SUBNET",
Short: "Change a subnet", Short: "Change a subnet",
Long: `Changes a subnet. Only what is given changes. --pool replaces all pools; 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 in the order given; --add-pool adds one at the end, or changes the order
the others), --unset-option removes one. 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 --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`, steward subnet update office --option ntp-servers=10.0.10.2 --unset-option time-offset`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -191,6 +220,12 @@ the others), --unset-option removes one.
if fl.Changed("comment") { if fl.Changed("comment") {
in.Comment = &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") { if fl.Changed("lease-time") {
n := 0 n := 0
if leaseTime != "default" { if leaseTime != "default" {
@@ -216,7 +251,7 @@ the others), --unset-option removes one.
} }
found := false found := false
for i, p := range list { 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:]...) list = append(list[:i], list[i+1:]...)
found = true found = true
break 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)) 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 in.Pools = &list
} }
if len(opts) > 0 || len(unsetOpts) > 0 { 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(&iface, "interface", "", "a new interface")
f.StringVar(&prefix, "prefix", "", "a new prefix") f.StringVar(&prefix, "prefix", "", "a new prefix")
f.StringArrayVar(&pools, "pool", nil, "replace the pools (repeat for several)") 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.StringArrayVar(&removePools, "remove-pool", nil, "remove a pool")
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d, or \"default\")") f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d, or \"default\")")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE") f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE")
@@ -339,6 +390,9 @@ func reservationCmd() *cobra.Command {
lease := "-" lease := "-"
if r.Lease != nil { if r.Lease != nil {
lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04") lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04")
if r.Lease.LastSeen != nil {
lease += ", seen " + fmtWhen(*r.Lease.LastSeen)
}
} }
state := "on" state := "on"
if r.Disabled { if r.Disabled {
@@ -374,7 +428,10 @@ func reservationCmd() *cobra.Command {
fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname)) fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname))
fmt.Fprintln(w, "Options:\t"+optionsText(r.Options)) fmt.Fprintln(w, "Options:\t"+optionsText(r.Options))
if r.Lease != nil { 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 { } else {
fmt.Fprintln(w, "Lease:\tnone now") 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(), 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), 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, 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()) }, eng, d, host, a.log, web.FS())
go d.Run(ctx) go d.Run(ctx)
hup := make(chan os.Signal, 1) 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 /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 /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 | | `GET /catalog` | the option catalog, value types, reserved codes, roles |
### Settings ### Settings
@@ -71,9 +71,13 @@ Creating answers 201, other changes 200.
```json ```json
{"lease_time": 43200, "ping_check": true, "ping_timeout_ms": 500, "decline_hold": 86400, {"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, `options` replaces the list. Each option is `{"option": NAME or NUMBER,
"value": TEXT}`, with `"type"` for numbers the catalog doesn't know "value": TEXT}`, with `"type"` for numbers the catalog doesn't know
([options.md](options.md)). ([options.md](options.md)).
@@ -90,12 +94,17 @@ Creating answers 201, other changes 200.
```json ```json
{"name": "office", "interface": "eth0", "prefix": "10.0.10.0/24", {"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": ""} "options": [{"option": "routers", "value": "10.0.10.1"}], "disabled": false, "comment": ""}
``` ```
`pools` and `options` replace the lists; `lease_time` 0 means the `pools` and `options` replace the lists; the pools keep their order. A
settings'. 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 ### 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 | | `GET /leases/{ip}` | one |
| `DELETE /leases/{ip}` | forget it (audited) | | `DELETE /leases/{ip}` | forget it (audited) |
| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment"}` | | `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 ### 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 list` | subnets, with interface, pools, use, reservations, lease time |
| `subnet show SUBNET` | one subnet; SUBNET is its ID, name or prefix | | `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 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] [--lease-time 1h\|default] [--option NAME=VALUE]... [--unset-option NAME]... [--comment]` | change what is given | | `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 enable\|disable SUBNET` | answer its clients, or not |
| `subnet delete SUBNET [-y]` | delete it and its reservations | | `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 | | | 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 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 add IP MAC [--hostname H] [--client-id HEX] [--option NAME=VALUE]... [--comment C]` | `-` for MAC with `--client-id` |
| `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--option]... [--unset-option]... [--comment]` | | | `reservation 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 ### activity
`steward activity [--mac MAC] [--interface I] [--limit N] [-f]`: the latest `steward activity [--mac MAC] [--subnet SUBNET] [--interface I] [--limit N] [-f]`:
DHCP messages, received and sent, oldest first. `-f` keeps watching, which the latest DHCP messages, received and sent, oldest first. `--subnet`
is useful while a device tries to get an address: takes an ID, name or prefix; a message without an address (a DISCOVER,
"no address") shows for every subnet on its interface. `-f` keeps
watching, which is useful while a device tries to get an address:
``` ```
11:02:13.120 eth0 DHCPDISCOVER 3c:22:fb:11:22:33 - adams-macbook 11:02:13.120 eth0 DHCPDISCOVER 3c:22:fb:11:22:33 - adams-macbook
@@ -113,9 +115,9 @@ sent.
| Command | | | Command | |
|---|---| |---|---|
| `settings show` | | | `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)) | | `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 ### 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.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)) | | `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)) | | `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 | | `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 | | `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 | | `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 * **Leases** in SQLite, written before the client is answered, so a crash
or restart never hands out an address twice. or restart never hands out an address twice.
* **Conflict checks**: an address is pinged before it is offered; one that * **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; * **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 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 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; * addresses and options per interface, and reservations;
* the ping check, against a container that squats on a pool address; * 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; * init-reboot, renewal and release with dhcpcd;
* a client moved to another VLAN: NAK, then that VLAN's address; * a client moved to another VLAN: NAK, then that VLAN's address;
* a full pool and a disabled subnet; * 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 | | `shared-networks` | their subnets, all on the shared network's interface |
| `reservations` with `hw-address` or `client-id` and `ip-address`, `hostname` | reservations | | `reservations` with `hw-address` or `client-id` and `ip-address`, `hostname` | reservations |
| `decline-probation-period` | the decline hold | | `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 | | `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) | | 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 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 is the server identifier clients see, and the one they renew their lease
with. `steward interface list` shows the server's interfaces with their 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 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 (`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; 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 3. the address the client asks for (option 50), if it is free and in a
pool; pool;
4. a free pool address: one never handed out before, else the one that has 4. a free pool address: one never handed out before, in the subnet's
been free the longest. [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 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. 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 When the client confirms with a REQUEST, the lease is saved, and only
then is the ACK sent. 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 ## Leases
| State | Meaning | | State | Meaning |
@@ -143,6 +180,17 @@ then is the ACK sent.
`steward lease list` shows active and held-back leases; `--all` shows `steward lease list` shows active and held-back leases; `--all` shows
the others too. Leases that ended more than 30 days ago are deleted. 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 `steward lease delete IP` frees an address at once. A client still using
it gets a NAK when it next renews, then asks for a new address. it gets a NAK when it next renews, then asks for a new address.
+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 | | `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 | | `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 | | `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 ## Options
@@ -45,6 +46,7 @@ These apply to options anywhere (settings, subnets, reservations).
| `pool_outside_subnet` | error | a pool not inside the subnet | | `pool_outside_subnet` | error | a pool not inside the subnet |
| `pool_network_address` | error | a pool with the network or broadcast address | | `pool_network_address` | error | a pool with the network or broadcast address |
| `pool_overlap` | error | two pools overlap | | `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_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 | | `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 | | `interface_unknown` | warning, host | the interface doesn't exist on this machine. It is served once it appears |
+7 -1
View File
@@ -68,15 +68,21 @@ type SettingsInput struct {
PingTimeout *int `json:"ping_timeout_ms,omitempty"` PingTimeout *int `json:"ping_timeout_ms,omitempty"`
DeclineHold *int `json:"decline_hold,omitempty"` DeclineHold *int `json:"decline_hold,omitempty"`
NotAuthoritative *bool `json:"not_authoritative,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 // 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 { type SubnetInput struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Interface *string `json:"interface,omitempty"` Interface *string `json:"interface,omitempty"`
Prefix *string `json:"prefix,omitempty"` Prefix *string `json:"prefix,omitempty"`
Pools *[]string `json:"pools,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"` LeaseTime *int `json:"lease_time,omitempty"`
Options *[]OptionInput `json:"options,omitempty"` Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"` Disabled *bool `json:"disabled,omitempty"`
+24
View File
@@ -10,6 +10,7 @@ import (
"net/netip" "net/netip"
"net/url" "net/url"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -35,6 +36,17 @@ type Config struct {
Backup Backup `yaml:"backup"` Backup Backup `yaml:"backup"`
// Jarl is the Jarl server Steward uses for DNS (docs/jarl.md). // Jarl is the Jarl server Steward uses for DNS (docs/jarl.md).
Jarl Jarl `yaml:"jarl"` 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 // TLS is the web UI's certificate: from files, or obtained through ACME
@@ -173,6 +185,7 @@ func Default() *Config {
return &Config{ return &Config{
Listen: []string{"127.0.0.1:8067"}, Listen: []string{"127.0.0.1:8067"},
TrustedProxies: []string{}, TrustedProxies: []string{},
Interfaces: Interfaces{Exclude: []string{}},
Socket: "/run/steward/steward.sock", Socket: "/run/steward/steward.sock",
Database: "/var/lib/steward/steward.db", Database: "/var/lib/steward/steward.db",
Log: Log{Level: "info", Format: "text"}, Log: Log{Level: "info", Format: "text"},
@@ -225,6 +238,11 @@ func (c *Config) check() error {
return fmt.Errorf("trusted_proxies: %v", err) 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) { if c.Database == "" || !filepath.IsAbs(c.Database) {
return fmt.Errorf("database must be an absolute path") return fmt.Errorf("database must be an absolute path")
} }
@@ -354,6 +372,12 @@ tls:
# ca_cert: "" # a CA to trust for Jarl's certificate # ca_cert: "" # a CA to trust for Jarl's certificate
# insecure: false # 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 # Reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are
# believed. # believed.
trusted_proxies: [] 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: {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: '*.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://", "{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 { for yml, want := range cases {
path := filepath.Join(t.TempDir(), "s.yaml") 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) return hold(requested, sub, true)
} }
} }
// 4. A free address: one never handed out, else the one free the // 4. A free address: one never handed out, in the subnet's order
// longest. // (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 oldest netip.Addr
var oldestSub *subnet var oldestSub *subnet
var oldestEnd time.Time var oldestEnd time.Time
for _, sub := range subs { for _, sub := range subs {
for _, pool := range sub.Pools { for _, pool := range sub.Pools {
for v := model.U32(pool.Start); v <= model.U32(pool.End) && v != 0; v++ { eachIn(pool, false, func(ip netip.Addr) bool {
ip := model.FromU32(v) if l := s.leases[ip]; l != nil && s.usable(cfg, own, sub, ip, cl, now) && (!oldest.IsValid() || l.End.Before(oldestEnd)) {
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) {
oldest, oldestSub, oldestEnd = ip, sub, l.End oldest, oldestSub, oldestEnd = ip, sub, l.End
} }
if v == model.U32(pool.End) { return false
break })
}
}
} }
} }
if oldest.IsValid() { 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) return s.nak(cfg, iface, p, sub, why)
} }
start, host := now, p.HostName() start, host := now, p.HostName()
var seen *time.Time
if l := s.leases[ip]; l != nil && l.Owns(cl.mac, cl.id) { if l := s.leases[ip]; l != nil && l.Owns(cl.mac, cl.id) {
seen = l.LastSeen
if l.ActiveAt(now) { if l.ActiveAt(now) {
start = l.Start 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, 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) err := s.putLease(ctx, l)
delete(s.offers, ip) delete(s.offers, ip)
s.mu.Unlock() 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) 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() defer t.Stop()
prune := time.NewTicker(time.Hour) prune := time.NewTicker(time.Hour)
defer prune.Stop() defer prune.Stop()
go s.watchSeen(ctx)
for { for {
s.reconcile(ctx) s.reconcile(ctx)
select { 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") conn, err := icmp.ListenPacket("udp4", "0.0.0.0")
if err != nil { if err != nil {
p.once.Do(func() { 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) p.Unavailable.Store(msg)
if p.Log != nil { if p.Log != nil {
p.Log.Warn(msg) p.Log.Warn(msg)
@@ -41,7 +41,7 @@ func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Durati
} }
defer conn.Close() defer conn.Close()
seq := int(p.seq.Add(1) & 0xffff) 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) b, err := msg.Marshal(nil)
if err != nil { if err != nil {
return false 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" "context"
"errors" "errors"
"log/slog" "log/slog"
"math/rand/v2"
"net" "net"
"net/netip" "net/netip"
"sort" "sort"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -24,6 +24,7 @@ type LeaseStore interface {
PutLease(ctx context.Context, l *model.Lease) error PutLease(ctx context.Context, l *model.Lease) error
DeleteLease(ctx context.Context, ip netip.Addr) error DeleteLease(ctx context.Context, ip netip.Addr) error
PruneLeases(ctx context.Context, before time.Time) (int64, 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 // Interfaces reports a network interface's IPv4 addresses with their
@@ -32,7 +33,8 @@ type Interfaces interface {
Addrs(name string) ([]netip.Prefix, error) 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 { type Pinger interface {
Ping(ctx context.Context, a netip.Addr, timeout time.Duration) bool Ping(ctx context.Context, a netip.Addr, timeout time.Duration) bool
} }
@@ -51,6 +53,8 @@ type Server struct {
ifaces Interfaces ifaces Interfaces
pinger Pinger pinger Pinger
now func() time.Time 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 and ClientPort are 67 and 68 (tests use others).
ServerPort, ClientPort int ServerPort, ClientPort int
@@ -81,7 +85,7 @@ func New(log *slog.Logger, st LeaseStore, ifaces Interfaces, pinger Pinger) *Ser
if ifaces == nil { if ifaces == nil {
ifaces = &HostInterfaces{} 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)} 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.cfg.Store(compile(&model.State{Settings: model.DefaultSettings()}))
s.events.max = 1000 s.events.max = 1000
@@ -342,15 +346,15 @@ func (e *events) add(ev Event) {
e.counts[ev.Type]++ e.counts[ev.Type]++
} }
// Events returns the latest events, newest first, optionally only those of // Events returns the latest events, newest first, optionally only those
// one MAC address or interface. // keep accepts.
func (s *Server) Events(limit int, mac, iface string) []Event { func (s *Server) Events(limit int, keep func(Event) bool) []Event {
s.events.mu.Lock() s.events.mu.Lock()
defer s.events.mu.Unlock() defer s.events.mu.Unlock()
out := []Event{} out := []Event{}
for i := len(s.events.list) - 1; i >= 0 && (limit <= 0 || len(out) < limit); i-- { for i := len(s.events.list) - 1; i >= 0 && (limit <= 0 || len(out) < limit); i-- {
ev := s.events.list[i] ev := s.events.list[i]
if mac != "" && !strings.EqualFold(ev.MAC, mac) || iface != "" && ev.Interface != iface { if keep != nil && !keep(ev) {
continue continue
} }
out = append(out, ev) 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. // lists in their canonical form, so equal configurations compare equal.
func Normalize(st *model.State) { func Normalize(st *model.State) {
st.Settings.Options = normOptions(st.Settings.Options) st.Settings.Options = normOptions(st.Settings.Options)
if st.Settings.AddressOrder == model.OrderSequential {
st.Settings.AddressOrder = "" // the default
}
if st.Subnets == nil { if st.Subnets == nil {
st.Subnets = []*model.Subnet{} st.Subnets = []*model.Subnet{}
} }
@@ -429,7 +432,10 @@ func Normalize(st *model.State) {
if s.Prefix.IsValid() && s.Prefix.Addr().Is4() { if s.Prefix.IsValid() && s.Prefix.Addr().Is4() {
s.Prefix = s.Prefix.Masked() 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 { for _, r := range st.Reservations {
r.Options = normOptions(r.Options) r.Options = normOptions(r.Options)
+28 -1
View File
@@ -90,6 +90,9 @@ type importer struct {
opts Options opts Options
res *Result res *Result
globals []string // interfaces-config interfaces 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. // ParseConfig reads a kea-dhcp4.conf.
@@ -131,6 +134,17 @@ func jsonError(b []byte, err error) error {
return err 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) { func (im *importer) note(format string, args ...any) {
im.res.Notes = append(im.res.Notes, fmt.Sprintf(format, args...)) 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. // note.
var ( var (
handledKeys = map[string]bool{"interfaces-config": true, "valid-lifetime": true, "option-data": true, "subnet4": true, "shared-networks": true, 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{ ignoredKeys = map[string]string{
"lease-database": "Steward keeps leases in its own database", "control-socket": "Steward has its own API", "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", "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) im.globals = append(im.globals, name)
} }
} }
get(d, "allocator", &im.allocator)
set := &im.res.State.Settings set := &im.res.State.Settings
var vl int var vl int
if ok, err := get(d, "valid-lifetime", &vl); err != nil { 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 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 == "" { if ok, _ := get(s, "interface", &iface); ok && iface == "" {
iface = "" iface = ""
} }
+21
View File
@@ -168,3 +168,24 @@ func TestParseLeases(t *testing.T) {
t.Error("a file without Kea's columns was accepted") 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 // addresses that don't belong on the interface, for networks where
// another DHCP server shares the segment. Off by default. // another DHCP server shares the segment. Off by default.
NotAuthoritative bool `json:"not_authoritative"` 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. // DefaultSettings are the settings of a new database.
@@ -101,6 +140,12 @@ type Subnet struct {
Interface string `json:"interface"` Interface string `json:"interface"`
Prefix netip.Prefix `json:"prefix"` Prefix netip.Prefix `json:"prefix"`
Pools []Pool `json:"pools"` 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 overrides the settings' lease time (0: inherit).
LeaseTime int `json:"lease_time"` LeaseTime int `json:"lease_time"`
Options []OptionValue `json:"options"` Options []OptionValue `json:"options"`
@@ -122,9 +167,25 @@ func (s *Subnet) Label() string {
type Pool struct { type Pool struct {
Start netip.Addr `json:"start"` Start netip.Addr `json:"start"`
End netip.Addr `json:"end"` 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. // Contains reports whether a is in the pool.
func (p Pool) Contains(a netip.Addr) bool { 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 // 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) { func ParsePool(s string) (Pool, error) {
s = strings.TrimSpace(s) 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 { if a, b, ok := strings.Cut(s, "-"); ok {
start, err1 := netip.ParseAddr(strings.TrimSpace(a)) start, err1 := netip.ParseAddr(strings.TrimSpace(a))
end, err2 := netip.ParseAddr(strings.TrimSpace(b)) 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 is where the client was last seen.
Interface string `json:"interface"` Interface string `json:"interface"`
Updated time.Time `json:"updated"` 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 // 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 { if in.NotAuthoritative != nil {
set.NotAuthoritative = *in.NotAuthoritative set.NotAuthoritative = *in.NotAuthoritative
} }
if in.AddressOrder != nil {
set.AddressOrder = orderInput(*in.AddressOrder)
}
return nil return nil
}, func(st *model.State) any { return st.Settings }) }, 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 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. // applySubnetInput sets the given fields.
func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error { func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
if in.Name != nil { if in.Name != nil {
@@ -219,6 +232,12 @@ func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
sn.Pools = append(sn.Pools, pool) 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 { if in.LeaseTime != nil {
sn.LeaseTime = *in.LeaseTime 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() st := s.eng.State()
out := []api.HostInterface{} out := []api.HostInterface{}
for name, ps := range all { for name, ps := range all {
if s.excludedInterface(name) {
continue
}
hi := api.HostInterface{Name: name, Addresses: []string{}, Subnets: []int64{}} hi := api.HostInterface{Name: name, Addresses: []string{}, Subnets: []int64{}}
for _, a := range ps { for _, a := range ps {
hi.Addresses = append(hi.Addresses, a.String()) 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). // Subnets may name interfaces that don't exist (yet).
for _, sn := range st.Subnets { 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 found := false
for i := range out { for i := range out {
if out[i].Name == sn.Interface { 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 { if err := require(p, auth.ResLeases, false); err != nil {
return err 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)) out := make([]api.Event, len(evs))
for i, e := range evs { for i, e := range evs {
out[i] = api.Event(e) out[i] = api.Event(e)
+15
View File
@@ -12,6 +12,7 @@ import (
"net/http" "net/http"
"net/netip" "net/netip"
"os" "os"
"path"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -44,6 +45,20 @@ type Config struct {
// ACME keeps the certificate stewardd obtains itself (tls.acme; nil // ACME keeps the certificate stewardd obtains itself (tls.acme; nil
// when off). // when off).
ACME *certs.Manager 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. // Interfaces lists the host's interfaces.
+54
View File
@@ -11,6 +11,7 @@ import (
"net/http/cookiejar" "net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"net/url"
"strings" "strings"
"testing" "testing"
@@ -246,6 +247,16 @@ func TestLeasesThroughDHCP(t *testing.T) {
if len(evs) != 4 || evs[0].Type != "DHCPACK" { if len(evs) != 4 || evs[0].Type != "DHCPACK" {
t.Errorf("events %+v", evs) t.Errorf("events %+v", evs)
} }
// 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 { if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 204 {
t.Errorf("delete lease: %d", code) t.Errorf("delete lease: %d", code)
} }
@@ -380,3 +391,46 @@ func TestTokenRotation(t *testing.T) {
t.Errorf("a token rotating itself: %d", code) 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 ( import (
"context" "context"
"database/sql"
"net/netip" "net/netip"
"time" "time"
"steward/internal/model" "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. // LoadLeases reads every lease.
func (s *Store) LoadLeases(ctx context.Context) ([]*model.Lease, error) { 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() { for rows.Next() {
l := &model.Lease{} l := &model.Lease{}
var ip, start, end, updated string 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 return nil, err
} }
l.IP, _ = netip.ParseAddr(ip) l.IP, _ = netip.ParseAddr(ip)
l.Start, l.End, l.Updated = parseTS(start), parseTS(end), parseTS(updated) l.Start, l.End, l.Updated = parseTS(start), parseTS(end), parseTS(updated)
l.LastSeen = parseTSPtr(seen)
out = append(out, l) out = append(out, l)
} }
return out, rows.Err() return out, rows.Err()
@@ -46,10 +49,26 @@ func (s *Store) PutLeases(ctx context.Context, ls []*model.Lease) error {
} }
defer tx.Rollback() defer tx.Rollback()
for _, l := range ls { 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, 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`, 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)); err != nil { 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 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 { if st.Settings.Options == nil {
st.Settings.Options = []model.OptionValue{} st.Settings.Options = []model.OptionValue{}
} }
rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, 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 { if err != nil {
return nil, err return nil, err
} }
@@ -34,7 +34,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
sn := &model.Subnet{} sn := &model.Subnet{}
var prefix, pools, opts string var prefix, pools, opts string
var disabled int 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 return nil, err
} }
sn.Prefix, _ = netip.ParsePrefix(prefix) 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 { if err := json.Unmarshal(c.After, &sn); err != nil {
return err return err
} }
_, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, 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, 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`, 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.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.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 return err
case model.KindReservation: case model.KindReservation:
if c.After == nil { if c.After == nil {
+9
View File
@@ -197,6 +197,15 @@ ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
// 3: disabled reservations. // 3: disabled reservations.
` `
ALTER TABLE reservations ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0; 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) { 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) 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") 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 { 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") 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) network, broadcast := s.Prefix.Addr(), model.Last(s.Prefix)
for i, p := range s.Pools { 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) { 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 continue
} }
if !s.Prefix.Contains(p.Start) || !s.Prefix.Contains(p.End) { 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 continue
} }
if p.Contains(network) || p.Contains(broadcast) { 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] { for _, o := range s.Pools[:i] {
if o.Start.Is4() && o.End.Is4() && !(p.End.Less(o.Start) || o.End.Less(p.Start)) { 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 { for _, p := range s.Pools {
if p.Contains(a) { 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()) { if s.Prefix.Contains(a.Addr()) {
for _, p := range s.Pools { for _, p := range s.Pools {
if p.Contains(a.Addr()) { 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 return
+7 -4
View File
@@ -2,16 +2,18 @@
import { h, mount, input, select, debounce } from "../ui.js"; import { h, mount, input, select, debounce } from "../ui.js";
import { get } from "../api.js"; import { get } from "../api.js";
import { onLeave } from "../app.js"; import { onLeave } from "../app.js";
import { subnetLabel } from "../widgets.js";
export async function render(el, params, query) { 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 mac = input({ placeholder: "MAC address", value: query.get("mac") || "", class: "mono" });
const iface = select([["", "all interfaces"], ...ifaces.filter((i) => i.subnets.length).map((i) => [i.name, i.name])], query.get("interface") || ""); const iface = select([["", "all interfaces"], ...ifaces.filter((i) => i.subnets.length).map((i) => [i.name, i.name])], query.get("interface") || "");
const out = h("div"); const out = h("div");
let live = true; let live = true;
const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause"); const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause");
const draw = async () => { const draw = async () => {
const evs = await get("/events", { mac: mac.value.trim(), interface: iface.value, limit: 300 }); const evs = await get("/events", { mac: mac.value.trim(), subnet: subnetSel.value, interface: iface.value, limit: 300 });
mount(out, evs.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" }, mount(out, evs.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
h("thead", null, h("tr", null, ["Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))), h("thead", null, h("tr", null, ["Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))),
h("tbody", null, evs.map((e) => { h("tbody", null, evs.map((e) => {
@@ -26,14 +28,15 @@ export async function render(el, params, query) {
h("td", { class: "mono" }, e.ip || ""), h("td", { class: "mono" }, e.ip || ""),
h("td", null, e.hostname || ""), h("td", null, e.hostname || ""),
h("td", { class: "detail" }, e.detail || "")); h("td", { class: "detail" }, e.detail || ""));
})))) : 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)); mac.addEventListener("input", debounce(draw, 300));
subnetSel.addEventListener("change", draw);
iface.addEventListener("change", draw); iface.addEventListener("change", draw);
mount(el, mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Activity"), 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: "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)); h("div", { class: "panel" }, out));
await draw(); await draw();
const t = setInterval(() => { if (live) draw().catch(() => {}); }, 3000); const t = setInterval(() => { if (live) draw().catch(() => {}); }, 3000);
@@ -20,6 +20,7 @@ export function leasesTable(leases, done, opts = {}) {
{ label: "Host name", render: leaseHost }, { label: "Host name", render: leaseHost },
{ label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] }, { label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] },
{ label: "Ends", render: (l) => time(l.end) }, { 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 || "–" }, opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" },
{ label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) }, { label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) },
{ label: "", tdClass: "actions-cell", render: (l) => [ { label: "", tdClass: "actions-cell", render: (l) => [
@@ -1,7 +1,8 @@
// Reservations: fixed addresses for clients. // 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 { get, change, can } from "../api.js";
import { optionsEditor, subnetLabel } from "../widgets.js"; import { optionsEditor, subnetLabel } from "../widgets.js";
import { onLeave } from "../app.js";
// reservationsTable lists reservations with Edit and Delete. // reservationsTable lists reservations with Edit and Delete.
export function reservationsTable(rs, done, opts = {}) { 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" }, "–") }, { 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) : "–" }, 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 { 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") }, : badge("offline") },
{ label: "Options", render: (r) => r.options.length ? h("span", { class: "mono small" }, r.options.map((o) => o.name + "=" + o.value).join("; ")) : "" }, { 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 }, { 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 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 search = input({ type: "search", placeholder: "address, MAC or host name" });
const out = h("div"); const out = h("div");
let gen = 0;
const draw = async () => { const draw = async () => {
const my = ++gen;
const q = search.value.trim().toLowerCase(); const q = search.value.trim().toLowerCase();
const rs = (await get("/reservations", { subnet: subnetSel.value })).filter((r) => 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)); !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." })); mount(out, reservationsTable(rs, draw, { empty: q || subnetSel.value ? "None match." : "No reservations yet." }));
}; };
subnetSel.addEventListener("change", draw); subnetSel.addEventListener("change", draw);
search.addEventListener("input", debounce(draw, 200)); search.addEventListener("input", debounce(draw, 200));
mount(el, mount(el,
h("div", { class: "page-head" }, 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), 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: "filter-row" }, subnetSel, search),
h("div", { class: "panel" }, out)); h("div", { class: "panel" }, out));
await draw(); 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 // Settings: the default lease time, conflict checks, authority, and the
// options every subnet gets. // 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 { 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"; import { rerender } from "../app.js";
export async function render(el) { export async function render(el) {
const s = await get("/settings"); const s = await get("/settings");
const write = can("config", true); const write = can("config", true);
const lease = input({ value: fmtSeconds(s.lease_time || 43200) }); 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 }); const timeout = input({ type: "number", min: 50, max: 5000, value: s.ping_timeout_ms || 500 });
timeout.style.width = "7em"; timeout.style.width = "7em";
const hold = input({ value: fmtSeconds(s.decline_hold || 86400) }); 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 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 opts = await optionsEditor(s.options);
const err = h("div"); const err = h("div");
const save = async (dry) => { 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; } if (lt === null || dh === null) { mount(err, h("div", { class: "msg err" }, "Write times as 12h, 1d or 30m.")); return; }
try { try {
const r = await change("PATCH", "/settings", { lease_time: lt, ping_check: ping.input.checked, ping_timeout_ms: parseInt(timeout.value, 10) || 0, 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(); if (!dry && r.applied) rerender();
} catch (e) { mount(err, errorBox(e)); } } 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("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("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("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("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.")), field("Authority", auth, "Turn this off only if another DHCP server serves the same network.")),
err, err,
+3 -2
View File
@@ -1,7 +1,7 @@
// A subnet's page: its leases, its reservations and its settings. // A subnet's page: its leases, its reservations and its settings.
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox } from "../ui.js"; import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox } from "../ui.js";
import { get, change, can } from "../api.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 { leasesTable } from "./leases.js";
import { reservationsTable, reservationDialog } from "./reservations.js"; import { reservationsTable, reservationDialog } from "./reservations.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
@@ -19,7 +19,8 @@ export async function render(el, params) {
h("div", { class: "grow" }, h("div", { class: "grow" },
h("h1", null, subnetLabel(s), " ", s.disabled ? badge("disabled", "off") : null), h("h1", null, subnetLabel(s), " ", s.disabled ? badge("disabled", "off") : null),
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ", h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
s.pools.length ? h("span", { class: "mono" }, s.pools.map((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")), " · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
h("div", { class: "subnet-usage" }, usageBar(s.usage)), h("div", { class: "subnet-usage" }, usageBar(s.usage)),
can("config", true) ? h("div", { class: "actions" }, h("button", { can("config", true) ? h("div", { class: "actions" }, h("button", {
+2 -2
View File
@@ -1,12 +1,12 @@
// The subnets, and the New subnet dialog. // The subnets, and the New subnet dialog.
import { h, mount, table, modal, errorBox, badge } from "../ui.js"; import { h, mount, table, modal, errorBox, badge } from "../ui.js";
import { get, change, can } from "../api.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"; import { toggleSubnet } from "./subnet.js";
export async function render(el, params, query) { export async function render(el, params, query) {
const subnets = await get("/subnets"); 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, mount(el,
h("div", { class: "page-head" }, h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Subnets"), 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-rows { display: flex; flex-direction: column; gap: 6px; }
.pool-row { display: flex; gap: 6px; align-items: center; } .pool-row { display: flex; gap: 6px; align-items: center; }
.pool-row input { font-family: var(--mono); width: 18em; } .pool-row input { font-family: var(--mono); width: 18em; }
.pool-row select { width: auto; }
/* Activity: one line per message. */ /* Activity: one line per message. */
table.activity td { white-space: nowrap; } table.activity td { white-space: nowrap; }
table.activity td.detail { white-space: normal; color: var(--muted); } table.activity td.detail { white-space: normal; color: var(--muted); }
+42 -12
View File
@@ -114,25 +114,51 @@ const examples = {
// ---- pools // ---- pools
// poolsEditor edits a subnet's pools; value() is ["first-last", ...]. // 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) { export function poolsEditor(pools, placeholder) {
const rows = h("div", { class: "pool-rows" }); const rows = h("div", { class: "pool-rows" });
const box = h("div", null, rows); const box = h("div", null, rows);
const list = []; let list = [];
const addRow = (v) => { const addRow = (v, order) => {
const inp = input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" }); const it = { inp: input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" }) };
const row = h("div", { class: "pool-row" }, inp, it.sel = select([["", "subnet's order"], ...ADDRESS_ORDERS.map((o) => [o, o])], order || "", { title: "The order addresses of this pool are handed out in" });
h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { row.remove(); list.splice(list.indexOf(inp), 1); } }, "✕")); it.row = h("div", { class: "pool-row" }, it.inp, it.sel,
list.push(inp); h("button", { type: "button", class: "small", title: "Move this pool up: it is used before the ones below", onclick: () => {
rows.append(row); const i = list.indexOf(it);
return inp; 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.append(h("button", { type: "button", class: "small", onclick: () => addRow("").focus() }, "+ Add pool"));
box.value = () => list.map((i) => i.value.trim()).filter((s) => s !== ""); box.value = () => list.map((it) => {
box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].value) list[0].value = v; }; 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; 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 // ---- the subnet form
// v4 helpers for suggestions. // 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 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 prefix = input({ value: sn ? sn.prefix : "", placeholder: "10.0.10.0/24", class: "mono" });
const pools = poolsEditor(sn ? sn.pools : []); 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 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 opts = await optionsEditor(sn ? sn.options : [], settings.options);
const comment = input({ value: sn ? sn.comment : "" }); 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("Interface", h("div", null, ifaceSel, " ", ifaceOther)), ifaceHint,
field("Network", prefix, "The subnet's prefix; the mask clients get comes from it."), 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("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("Lease time", lease, "12h, 1d, 30m; empty for the default."),
field("Options", opts), field("Options", opts),
field("Comment", comment), field("Comment", comment),
@@ -199,7 +229,7 @@ export async function subnetForm(sn, settings) {
grid.values = () => { grid.values = () => {
const lt = parseSeconds(lease.value); const lt = parseSeconds(lease.value);
if (lt === null) throw new Error("Lease time: write it as 12h, 1d or 30m"); 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; if (sn) v.disabled = disabled.input.checked;
return v; 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 run "lease time back" 0 S subnet show iot
contains "Lease time: *1h" 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 # ---- restart: leases survive
docker exec ${P}-srv sh -c 'kill $(pidof stewardd)'; sleep 1 docker exec ${P}-srv sh -c 'kill $(pidof stewardd)'; sleep 1
run "restart stewardd" 0 start run "restart stewardd" 0 start
run "leases after the restart" 0 S lease list run "leases after the restart" 0 S lease list
contains "$NA.101 *02:00:00:00:00:01 *laptop *active" 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 run "the client still gets its address" 0 udhcpc a 02:00:00:00:00:01
contains "BOUND ip=$NA.101 " 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. run "audit log" 0 S audit --action lease.
contains "lease.delete" 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 # ---- backup
run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup