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:
1 parent
29fc991ce1
commit
8ee430276c
29 files changed
+653
-79
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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 = ¬
|
in.NotAuthoritative = ¬
|
||||||
}
|
}
|
||||||
|
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
@@ -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
@@ -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
@@ -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`) |
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 = ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 '';
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user