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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-18 18:13:25 +01:00
1 parent 29fc991ce1
commit 8ee430276c
29 files changed
+653 -79

No files matched your search

+7
View File
@@ -117,6 +117,13 @@ and a CLI, built the way Jarl is.
and last seen stay current. and last seen stay current.
- **Activity by subnet:** `steward activity --subnet`, a subnet filter on - **Activity by subnet:** `steward activity --subnet`, a subnet filter on
the Activity page, `GET /events?subnet=` (ID, name or prefix). 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.
+6 -1
View File
@@ -249,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",
@@ -316,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 {
@@ -344,6 +348,7 @@ reservation's own option of the same name wins.
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)
+60 -9
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")
+13 -4
View File
@@ -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
+3 -3
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 |
@@ -115,7 +115,7 @@ 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 (not those in `interfaces.exclude`) | | `interface list` | the server's interfaces, their addresses and subnets (not those in `interfaces.exclude`) |
+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) |
+37 -2
View File
@@ -121,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.
@@ -133,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 |
+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 |
+15 -9
View File
@@ -68,19 +68,25 @@ 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"`
LeaseTime *int `json:"lease_time,omitempty"` // PoolOrder is listed or random; AddressOrder sequential, reverse,
Options *[]OptionInput `json:"options,omitempty"` // random or "" (the settings').
Disabled *bool `json:"disabled,omitempty"` PoolOrder *string `json:"pool_order,omitempty"`
Comment *string `json:"comment,omitempty"` AddressOrder *string `json:"address_order,omitempty"`
LeaseTime *int `json:"lease_time,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
} }
// ReservationInput creates or changes a reservation (unset fields stay). // ReservationInput creates or changes a reservation (unset fields stay).
+11 -16
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() {
+81
View File
@@ -499,3 +499,84 @@ func TestLastSeen(t *testing.T) {
t.Errorf("pinged %v", h.ping.pinged) 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)
}
}
+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
}
}
}
+4 -1
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"errors" "errors"
"log/slog" "log/slog"
"math/rand/v2"
"net" "net"
"net/netip" "net/netip"
"sort" "sort"
@@ -52,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
@@ -82,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
+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)
}
}
}
+19
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
} }
+29
View File
@@ -405,3 +405,32 @@ func TestInterfacesExclude(t *testing.T) {
t.Errorf("interfaces: %+v", ifs) 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)
}
}
+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 {
+5
View File
@@ -201,6 +201,11 @@ ALTER TABLE reservations ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
// 4: when a lease's client last answered a ping. // 4: when a lease's client last answered a ping.
` `
ALTER TABLE leases ADD COLUMN last_seen TEXT; 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
+5 -3
View File
@@ -1,8 +1,8 @@
// 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) {
@@ -14,6 +14,7 @@ export async function render(el) {
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;
}; };
+14
View File
@@ -220,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