diff --git a/CHANGELOG.md b/CHANGELOG.md index c17c655..a6d4509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,13 @@ and a CLI, built the way Jarl is. and last seen stay current. - **Activity by subnet:** `steward activity --subnet`, a subnet filter on the Activity page, `GET /events?subnet=` (ID, name or prefix). +- **Address order** (docs/subnets.md): which never-used pool address a + new client gets, `sequential` (the lowest, as before), `reverse` or + `random`, in the settings, a subnet (`--address-order`) or a single + pool (`--pool 10.0.10.200-10.0.10.250:random`). A subnet's pools are + used in the order listed, or one at random (`--pool-order random`), + weighted by its never-used addresses. Pools now keep the order they are + given in. Kea's `allocator: random` is imported. - **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`, Rotate in the web UI): a new secret for a token, with an optional grace period for the old one, as in Jarl. diff --git a/cmd/steward/leases.go b/cmd/steward/leases.go index e8c0fce..95698d6 100644 --- a/cmd/steward/leases.go +++ b/cmd/steward/leases.go @@ -249,10 +249,11 @@ func settingsCmd() *cobra.Command { auth = "no (such requests get no answer)" } fmt.Fprintf(w, "Authoritative:\t%s\n", auth) + fmt.Fprintf(w, "Address order:\t%s (subnets and pools can set their own)\n", s.EffectiveAddressOrder()) }) }, }) - var leaseTime, ping, pingTimeout, hold, authoritative string + var leaseTime, ping, pingTimeout, hold, authoritative, addrOrder string var opts, unsetOpts []string set := &cobra.Command{ Use: "set", @@ -316,6 +317,9 @@ reservation's own option of the same name wins. not := !*a in.NotAuthoritative = ¬ } + if fl.Changed("address-order") { + in.AddressOrder = &addrOrder + } if len(opts) > 0 || len(unsetOpts) > 0 { var cur model.Settings if err := c.Get(ctx(), "/settings", nil, &cur); err != nil { @@ -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(&hold, "decline-hold", "", "how long an address in use by someone else is held back (24h)") f.StringVar(&authoritative, "authoritative", "", "refuse requests for addresses that don't belong on the interface: on or off") + f.StringVar(&addrOrder, "address-order", "", "which never-used pool address a new client gets: sequential (the lowest), reverse (the highest) or random") f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE for every subnet") f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option") cmd.AddCommand(set) diff --git a/cmd/steward/subnets.go b/cmd/steward/subnets.go index eecf1d1..8b60564 100644 --- a/cmd/steward/subnets.go +++ b/cmd/steward/subnets.go @@ -17,6 +17,20 @@ func subnetPath(ref string) string { return "/subnets/" + url.PathEscape(strings.Replace(ref, "/", "_", 1)) } +// orderText says how new addresses are chosen: "pools listed, addresses +// the settings' order". +func orderText(s *model.Subnet) string { + pools := "pools in the order listed" + if s.PoolOrder == model.PoolsRandom { + pools = "a pool at random" + } + addrs := "the settings' order" + if s.AddressOrder != "" { + addrs = s.AddressOrder + } + return pools + ", addresses " + addrs + " (unless a pool has its own)" +} + func poolsText(ps []model.Pool) string { var out []string for _, p := range ps { @@ -78,6 +92,7 @@ func subnetCmd() *cobra.Command { fmt.Fprintf(w, "Interface:\t%s\n", s.Interface) fmt.Fprintf(w, "Prefix:\t%s (mask %s)\n", s.Prefix, model.Mask(s.Prefix)) fmt.Fprintf(w, "Pools:\t%s\n", poolsText(s.Pools)) + fmt.Fprintf(w, "Order:\t%s\n", orderText(s.Subnet)) fmt.Fprintf(w, "Leased:\t%s, %d declined\n", usageText(s.Usage), s.Usage.Declined) fmt.Fprintf(w, "Reservations:\t%d (%d with a lease now)\n", s.Usage.Reservations, s.Usage.ReservationsLeased) lt := "the default" @@ -96,7 +111,7 @@ func subnetCmd() *cobra.Command { }, }) - var name, iface, leaseTime, comment, prefix string + var name, iface, leaseTime, comment, prefix, poolOrder, addrOrder string var pools, opts, addPools, removePools, unsetOpts []string add := &cobra.Command{ Use: "add PREFIX --interface IFACE", @@ -108,7 +123,11 @@ get its addresses. --pool 10.0.10.100-10.0.10.199 \ --option routers=10.0.10.1 --option domain-name-servers=10.0.10.2 -Pools are FIRST-LAST or a prefix; give --pool once per pool. Options are +Pools are FIRST-LAST or a prefix; give --pool once per pool. A new client +gets a never-used address: from the pools in the order given (or a pool at +random: --pool-order random), the lowest first (or --address-order reverse +or random). A pool can have its own order: --pool 10.0.10.200-10.0.10.250:random. +Options are NAME=VALUE ('steward option list' has the names); a number works for any other option, with its type for ones Steward doesn't know: 224:string=hi.`, Args: cobra.ExactArgs(1), @@ -134,6 +153,12 @@ other option, with its type for ones Steward doesn't know: 224:string=hi.`, } in.LeaseTime = &n } + if poolOrder != "" { + in.PoolOrder = &poolOrder + } + if addrOrder != "" { + in.AddressOrder = &addrOrder + } if len(opts) > 0 { o, err := optionsArg(opts) if err != nil { @@ -151,7 +176,9 @@ other option, with its type for ones Steward doesn't know: 224:string=hi.`, f := add.Flags() f.StringVar(&iface, "interface", "", "the network interface it is served on (required)") f.StringVar(&name, "name", "", "a name for it") - f.StringArrayVar(&pools, "pool", nil, "a pool: FIRST-LAST or a prefix (repeat for several)") + f.StringArrayVar(&pools, "pool", nil, "a pool: FIRST-LAST or a prefix, with :ORDER for its own address order (repeat for several)") + f.StringVar(&poolOrder, "pool-order", "", "which pool a new address comes from: listed (in the order given, the default) or random") + f.StringVar(&addrOrder, "address-order", "", "which address of a pool: sequential, reverse or random (default: the settings')") f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d; default: the settings')") f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE (repeat for several)") f.StringVar(&comment, "comment", "", "a comment") @@ -161,11 +188,13 @@ other option, with its type for ones Steward doesn't know: 224:string=hi.`, update := &cobra.Command{ Use: "update SUBNET", Short: "Change a subnet", - Long: `Changes a subnet. Only what is given changes. --pool replaces all pools; ---add-pool and --remove-pool change one. --option sets an option (keeping -the others), --unset-option removes one. + Long: `Changes a subnet. Only what is given changes. --pool replaces all pools, +in the order given; --add-pool adds one at the end, or changes the order +of a pool it already has; --remove-pool removes one. --option sets an +option (keeping the others), --unset-option removes one. steward subnet update office --add-pool 10.0.10.200-10.0.10.220 + steward subnet update office --address-order reverse --add-pool 10.0.10.200-10.0.10.220:random steward subnet update office --option ntp-servers=10.0.10.2 --unset-option time-offset`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -191,6 +220,12 @@ the others), --unset-option removes one. if fl.Changed("comment") { in.Comment = &comment } + if fl.Changed("pool-order") { + in.PoolOrder = &poolOrder + } + if fl.Changed("address-order") { + in.AddressOrder = &addrOrder + } if fl.Changed("lease-time") { n := 0 if leaseTime != "default" { @@ -216,7 +251,7 @@ the others), --unset-option removes one. } found := false for i, p := range list { - if pp, _ := model.ParsePool(p); pp == rp { + if pp, _ := model.ParsePool(p); pp.SameRange(rp) { list = append(list[:i], list[i+1:]...) found = true break @@ -226,7 +261,21 @@ the others), --unset-option removes one. return fmt.Errorf("the subnet has no pool %s (it has %s)", rp, poolsText(cur.Pools)) } } - list = append(list, addPools...) + // A pool it has already changes in place (its order). + adding: + for _, a := range addPools { + ap, err := model.ParsePool(a) + if err != nil { + return err + } + for i, p := range list { + if pp, _ := model.ParsePool(p); pp.SameRange(ap) { + list[i] = a + continue adding + } + } + list = append(list, a) + } in.Pools = &list } if len(opts) > 0 || len(unsetOpts) > 0 { @@ -252,7 +301,9 @@ the others), --unset-option removes one. f.StringVar(&iface, "interface", "", "a new interface") f.StringVar(&prefix, "prefix", "", "a new prefix") f.StringArrayVar(&pools, "pool", nil, "replace the pools (repeat for several)") - f.StringArrayVar(&addPools, "add-pool", nil, "add a pool") + f.StringArrayVar(&addPools, "add-pool", nil, "add a pool (FIRST-LAST[:ORDER]), or set the order of one it has") + f.StringVar(&poolOrder, "pool-order", "", "which pool a new address comes from: listed or random") + f.StringVar(&addrOrder, "address-order", "", "which address of a pool: sequential, reverse, random or default (the settings')") f.StringArrayVar(&removePools, "remove-pool", nil, "remove a pool") f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d, or \"default\")") f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE") diff --git a/docs/api.md b/docs/api.md index 833a2fe..17a9166 100644 --- a/docs/api.md +++ b/docs/api.md @@ -71,9 +71,13 @@ Creating answers 201, other changes 200. ```json {"lease_time": 43200, "ping_check": true, "ping_timeout_ms": 500, "decline_hold": 86400, - "not_authoritative": false, "options": [{"option": "domain-name-servers", "value": "10.0.10.2"}]} + "not_authoritative": false, "address_order": "sequential", + "options": [{"option": "domain-name-servers", "value": "10.0.10.2"}]} ``` +`address_order` is `sequential` (left out when it is), `reverse` or +`random` ([subnets.md](subnets.md#address-order)). + `options` replaces the list. Each option is `{"option": NAME or NUMBER, "value": TEXT}`, with `"type"` for numbers the catalog doesn't know ([options.md](options.md)). @@ -90,12 +94,17 @@ Creating answers 201, other changes 200. ```json {"name": "office", "interface": "eth0", "prefix": "10.0.10.0/24", - "pools": ["10.0.10.100-10.0.10.199", "10.0.10.224/28"], "lease_time": 0, + "pools": ["10.0.10.100-10.0.10.199", "10.0.10.224/28:random"], "pool_order": "listed", + "address_order": "default", "lease_time": 0, "options": [{"option": "routers", "value": "10.0.10.1"}], "disabled": false, "comment": ""} ``` -`pools` and `options` replace the lists; `lease_time` 0 means the -settings'. +`pools` and `options` replace the lists; the pools keep their order. A +pool's `:ORDER` is its own address order. `pool_order` is `listed` or +`random`; `address_order` is `sequential`, `reverse`, `random` or +`default` (the settings'). Subnets come back with pools as +`{"start", "end", "order"}`, and the defaults (`listed`, `default`) left +out. `lease_time` 0 means the settings'. ### Reservations diff --git a/docs/cli.md b/docs/cli.md index 68a38bb..56d8d4d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -62,8 +62,8 @@ since the start, and problems. |---|---| | `subnet list` | subnets, with interface, pools, use, reservations, lease time | | `subnet show SUBNET` | one subnet; SUBNET is its ID, name or prefix | -| `subnet add PREFIX --interface IFACE [--name N] [--pool A-B]... [--lease-time 12h] [--option NAME=VALUE]... [--comment C]` | add one | -| `subnet update SUBNET [--name] [--interface] [--prefix] [--pool]... [--add-pool] [--remove-pool] [--lease-time 1h\|default] [--option NAME=VALUE]... [--unset-option NAME]... [--comment]` | change what is given | +| `subnet add PREFIX --interface IFACE [--name N] [--pool A-B[:ORDER]]... [--pool-order listed\|random] [--address-order sequential\|reverse\|random] [--lease-time 12h] [--option NAME=VALUE]... [--comment C]` | add one | +| `subnet update SUBNET [--name] [--interface] [--prefix] [--pool]... [--add-pool] [--remove-pool] [--pool-order] [--address-order ...\|default] [--lease-time 1h\|default] [--option NAME=VALUE]... [--unset-option NAME]... [--comment]` | change what is given; `--add-pool` of a pool it has changes that pool's order ([subnets.md](subnets.md#address-order)) | | `subnet enable\|disable SUBNET` | answer its clients, or not | | `subnet delete SUBNET [-y]` | delete it and its reservations | @@ -115,7 +115,7 @@ sent. | Command | | |---|---| | `settings show` | | -| `settings set [--lease-time 1d] [--ping-check on\|off] [--ping-timeout 500ms] [--decline-hold 24h] [--authoritative on\|off] [--option NAME=VALUE]... [--unset-option NAME]...` | | +| `settings set [--lease-time 1d] [--ping-check on\|off] [--ping-timeout 500ms] [--decline-hold 24h] [--authoritative on\|off] [--address-order sequential\|reverse\|random] [--option NAME=VALUE]... [--unset-option NAME]...` | | | `option list` | the options Steward knows ([options.md](options.md)) | | `interface list` | the server's interfaces, their addresses and subnets (not those in `interfaces.exclude`) | diff --git a/docs/kea.md b/docs/kea.md index e7c12ac..9c88807 100644 --- a/docs/kea.md +++ b/docs/kea.md @@ -51,6 +51,7 @@ pings before it offers an address, and clients renew or rebind. | `shared-networks` | their subnets, all on the shared network's interface | | `reservations` with `hw-address` or `client-id` and `ip-address`, `hostname` | reservations | | `decline-probation-period` | the decline hold | +| `allocator` (global, shared network, subnet) | `random`: the subnet's pool and address order random. `iterative` and `flq` become the default order, the lowest never-used address (with a note for `flq`) | | `user-context.comment` or `comment` of a subnet | the subnet's comment | | the lease file: active and declined leases, and released ones | leases (the last line of each address wins) | diff --git a/docs/subnets.md b/docs/subnets.md index 2df6ba8..317f69e 100644 --- a/docs/subnets.md +++ b/docs/subnets.md @@ -121,8 +121,9 @@ For a DISCOVER, Steward offers, in this order: ended, as long as it is still free and in a pool; 3. the address the client asks for (option 50), if it is free and in a pool; -4. a free pool address: one never handed out before, else the one that has - been free the longest. +4. a free pool address: one never handed out before, in the subnet's + [address order](#address-order), else the one that has been free the + longest. An address is free when no other client has an active lease on it, it isn't held back, and it isn't offered to someone else in the last minute. @@ -133,6 +134,40 @@ tried. When the client confirms with a REQUEST, the lease is saved, and only then is the ACK sent. +## Address order + +Which never-used address a new client gets is set at three levels, each +overriding the one above: + +| Level | Setting | Values | +|---|---|---| +| settings | `steward settings set --address-order` | `sequential` (the lowest free address, the default), `reverse` (the highest), `random` | +| subnet | `--address-order` | the same, or `default` (the settings') | +| pool | `:ORDER` after the pool: `--pool 10.0.10.200-10.0.10.250:random` | the same, or `default` (the subnet's) | + +A subnet with several pools also has a **pool order**, `--pool-order`: + +* `listed` (the default): the pools are used in the order they are + listed; the next one only when the one before has no never-used address + left. `--pool` gives the order; in the web UI, ↑ moves a pool up. +* `random`: a pool at random, weighted by how many never-used addresses + each has, so addresses spread over all of them. With `random` addresses + too, every never-used address in the subnet is as likely as any other. + +``` +steward subnet update office --pool-order random --address-order reverse +steward subnet update office --add-pool 10.0.10.200-10.0.10.250:random # one pool's own order +steward subnet update office --add-pool 10.0.10.200-10.0.10.250:default # back to the subnet's +``` + +The order only picks among addresses **never handed out** (or whose lease +was deleted, or ended over 30 days ago). Once every free address has been +used before, the one free the longest is given, whatever the order: an +address that just came free isn't handed to the next device, so stale ARP +caches and DNS records don't point at the wrong one. Returning clients +keep their address (steps 2 and 3), so changing the order doesn't move +anyone. + ## Leases | State | Meaning | diff --git a/docs/validation.md b/docs/validation.md index 626ead6..b646729 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -17,6 +17,7 @@ interfaces. They come from stewardd, not from a configuration on its own. | `lease_time_range` | error | a lease time (settings or subnet) under 1 minute or over 365 days | | `ping_timeout_range` | error | the ping timeout isn't 50 to 5000 ms | | `decline_hold_range` | error | the decline hold isn't 1 minute to 30 days | +| `address_order` | error | an address order (settings, subnet or pool) that isn't `sequential`, `reverse` or `random` | ## Options @@ -45,6 +46,7 @@ These apply to options anywhere (settings, subnets, reservations). | `pool_outside_subnet` | error | a pool not inside the subnet | | `pool_network_address` | error | a pool with the network or broadcast address | | `pool_overlap` | error | two pools overlap | +| `pool_order` | error | a pool order that isn't `listed` or `random` | | `router_outside_subnet` | warning | a `routers` address outside the subnet: clients can't reach it | | `router_in_pool` | warning | a `routers` address inside a pool. The ping check keeps it from being handed out while it answers, but take it out of the pool | | `interface_unknown` | warning, host | the interface doesn't exist on this machine. It is served once it appears | diff --git a/internal/api/types.go b/internal/api/types.go index 21f6e7a..b84690b 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -68,19 +68,25 @@ type SettingsInput struct { PingTimeout *int `json:"ping_timeout_ms,omitempty"` DeclineHold *int `json:"decline_hold,omitempty"` NotAuthoritative *bool `json:"not_authoritative,omitempty"` + // AddressOrder is sequential, reverse or random. + AddressOrder *string `json:"address_order,omitempty"` } // SubnetInput creates or changes a subnet (unset fields stay). Pools are -// "FIRST-LAST" or prefixes. +// "FIRST-LAST" or prefixes, with ":ORDER" for a pool's own address order. type SubnetInput struct { - Name *string `json:"name,omitempty"` - Interface *string `json:"interface,omitempty"` - Prefix *string `json:"prefix,omitempty"` - Pools *[]string `json:"pools,omitempty"` - LeaseTime *int `json:"lease_time,omitempty"` - Options *[]OptionInput `json:"options,omitempty"` - Disabled *bool `json:"disabled,omitempty"` - Comment *string `json:"comment,omitempty"` + Name *string `json:"name,omitempty"` + Interface *string `json:"interface,omitempty"` + Prefix *string `json:"prefix,omitempty"` + Pools *[]string `json:"pools,omitempty"` + // PoolOrder is listed or random; AddressOrder sequential, reverse, + // random or "" (the settings'). + PoolOrder *string `json:"pool_order,omitempty"` + AddressOrder *string `json:"address_order,omitempty"` + LeaseTime *int `json:"lease_time,omitempty"` + Options *[]OptionInput `json:"options,omitempty"` + Disabled *bool `json:"disabled,omitempty"` + Comment *string `json:"comment,omitempty"` } // ReservationInput creates or changes a reservation (unset fields stay). diff --git a/internal/dhcpd/handle.go b/internal/dhcpd/handle.go index 0fed854..a88c629 100644 --- a/internal/dhcpd/handle.go +++ b/internal/dhcpd/handle.go @@ -156,29 +156,24 @@ func (s *Server) choose(cfg *config, iface string, subs []*subnet, cl client, re return hold(requested, sub, true) } } - // 4. A free address: one never handed out, else the one free the - // longest. + // 4. A free address: one never handed out, in the subnet's order + // (order.go), else the one free the longest. + for _, sub := range subs { + if ip, ok := s.neverUsed(cfg, own, sub, cl, now); ok { + return hold(ip, sub, true) + } + } var oldest netip.Addr var oldestSub *subnet var oldestEnd time.Time for _, sub := range subs { for _, pool := range sub.Pools { - for v := model.U32(pool.Start); v <= model.U32(pool.End) && v != 0; v++ { - ip := model.FromU32(v) - if !s.usable(cfg, own, sub, ip, cl, now) { - continue - } - l := s.leases[ip] - if l == nil { - return hold(ip, sub, true) - } - if !oldest.IsValid() || l.End.Before(oldestEnd) { + eachIn(pool, false, func(ip netip.Addr) bool { + if l := s.leases[ip]; l != nil && s.usable(cfg, own, sub, ip, cl, now) && (!oldest.IsValid() || l.End.Before(oldestEnd)) { oldest, oldestSub, oldestEnd = ip, sub, l.End } - if v == model.U32(pool.End) { - break - } - } + return false + }) } } if oldest.IsValid() { diff --git a/internal/dhcpd/handle_test.go b/internal/dhcpd/handle_test.go index 30efa93..f206cb6 100644 --- a/internal/dhcpd/handle_test.go +++ b/internal/dhcpd/handle_test.go @@ -499,3 +499,84 @@ func TestLastSeen(t *testing.T) { 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) + } +} diff --git a/internal/dhcpd/order.go b/internal/dhcpd/order.go new file mode 100644 index 0000000..eec1200 --- /dev/null +++ b/internal/dhcpd/order.go @@ -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 + } + } +} diff --git a/internal/dhcpd/server.go b/internal/dhcpd/server.go index 31347f7..1d2def2 100644 --- a/internal/dhcpd/server.go +++ b/internal/dhcpd/server.go @@ -7,6 +7,7 @@ import ( "context" "errors" "log/slog" + "math/rand/v2" "net" "net/netip" "sort" @@ -52,6 +53,8 @@ type Server struct { ifaces Interfaces pinger Pinger now func() time.Time + // randN returns a random number in [0, n) (address order random). + randN func(n int) int // ServerPort and ClientPort are 67 and 68 (tests use others). ServerPort, ClientPort int @@ -82,7 +85,7 @@ func New(log *slog.Logger, st LeaseStore, ifaces Interfaces, pinger Pinger) *Ser if ifaces == nil { ifaces = &HostInterfaces{} } - s := &Server{log: log, store: st, ifaces: ifaces, pinger: pinger, now: time.Now, ServerPort: 67, ClientPort: 68, + s := &Server{log: log, store: st, ifaces: ifaces, pinger: pinger, now: time.Now, randN: rand.IntN, ServerPort: 67, ClientPort: 68, leases: map[netip.Addr]*model.Lease{}, offers: map[netip.Addr]offer{}, changed: make(chan struct{}, 1)} s.cfg.Store(compile(&model.State{Settings: model.DefaultSettings()})) s.events.max = 1000 diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 33f51ca..00c639d 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -415,6 +415,9 @@ func put(st *model.State, kind string, id int64, data json.RawMessage) error { // lists in their canonical form, so equal configurations compare equal. func Normalize(st *model.State) { st.Settings.Options = normOptions(st.Settings.Options) + if st.Settings.AddressOrder == model.OrderSequential { + st.Settings.AddressOrder = "" // the default + } if st.Subnets == nil { st.Subnets = []*model.Subnet{} } @@ -429,7 +432,10 @@ func Normalize(st *model.State) { if s.Prefix.IsValid() && s.Prefix.Addr().Is4() { s.Prefix = s.Prefix.Masked() } - sort.SliceStable(s.Pools, func(i, j int) bool { return s.Pools[i].Start.Less(s.Pools[j].Start) }) + // The pools' order counts (pool_order listed): it is kept. + if s.PoolOrder == model.PoolsListed { + s.PoolOrder = "" + } } for _, r := range st.Reservations { r.Options = normOptions(r.Options) diff --git a/internal/kea/kea.go b/internal/kea/kea.go index c582c96..ee97090 100644 --- a/internal/kea/kea.go +++ b/internal/kea/kea.go @@ -90,6 +90,9 @@ type importer struct { opts Options res *Result globals []string // interfaces-config interfaces + // allocator is Dhcp4's allocator (iterative, random or flq). + allocator string + noted map[string]bool } // ParseConfig reads a kea-dhcp4.conf. @@ -131,6 +134,17 @@ func jsonError(b []byte, err error) error { return err } +// noteOnce adds a note once per key. +func (im *importer) noteOnce(key, format string, args ...any) { + if im.noted == nil { + im.noted = map[string]bool{} + } + if !im.noted[key] { + im.noted[key] = true + im.note(format, args...) + } +} + func (im *importer) note(format string, args ...any) { im.res.Notes = append(im.res.Notes, fmt.Sprintf(format, args...)) } @@ -154,7 +168,7 @@ func get[T any](o obj, key string, into *T) (bool, error) { // note. var ( handledKeys = map[string]bool{"interfaces-config": true, "valid-lifetime": true, "option-data": true, "subnet4": true, "shared-networks": true, - "reservations": true, "authoritative": true, "decline-probation-period": true} + "reservations": true, "authoritative": true, "decline-probation-period": true, "allocator": true} ignoredKeys = map[string]string{ "lease-database": "Steward keeps leases in its own database", "control-socket": "Steward has its own API", "loggers": "Steward logs to the journal", "renew-timer": "Steward sends half the lease time", @@ -195,6 +209,7 @@ func (im *importer) dhcp4(d obj) error { im.globals = append(im.globals, name) } } + get(d, "allocator", &im.allocator) set := &im.res.State.Settings var vl int if ok, err := get(d, "valid-lifetime", &vl); err != nil { @@ -346,6 +361,18 @@ func (im *importer) subnet(s obj, shared obj) error { } sn.Options = so } + alloc := im.allocator + if shared != nil { + get(shared, "allocator", &alloc) + } + get(s, "allocator", &alloc) + switch alloc { + case "random": + // Kea picks among all the subnet's free addresses. + sn.PoolOrder, sn.AddressOrder = model.PoolsRandom, model.OrderRandom + case "flq": + im.noteOnce("flq", "the allocator flq (free lease queue) became Steward's sequential order: a new client gets the lowest address never handed out, else the one free the longest") + } if ok, _ := get(s, "interface", &iface); ok && iface == "" { iface = "" } diff --git a/internal/kea/kea_test.go b/internal/kea/kea_test.go index 2f79ce0..098c6f4 100644 --- a/internal/kea/kea_test.go +++ b/internal/kea/kea_test.go @@ -168,3 +168,24 @@ func TestParseLeases(t *testing.T) { t.Error("a file without Kea's columns was accepted") } } + +func TestAllocator(t *testing.T) { + cfg := `{"Dhcp4": {"allocator": "random", "subnet4": [ + {"subnet": "192.168.5.0/24", "interface": "eth0", "pools": [{"pool": "192.168.5.10-192.168.5.20"}]}, + {"subnet": "192.168.6.0/24", "interface": "eth1", "allocator": "iterative", "pools": [{"pool": "192.168.6.10-192.168.6.20"}]}]}}` + res, err := ParseConfig([]byte(cfg), Options{}) + if err != nil { + t.Fatal(err) + } + if s := res.State.Subnets[0]; s.PoolOrder != model.PoolsRandom || s.AddressOrder != model.OrderRandom { + t.Errorf("random: %+v", s) + } + if s := res.State.Subnets[1]; s.PoolOrder != "" || s.AddressOrder != "" { + t.Errorf("iterative: %+v", s) + } + for _, sk := range res.Skipped { + if strings.Contains(sk, "allocator") { + t.Errorf("skipped %q", sk) + } + } +} diff --git a/internal/model/config.go b/internal/model/config.go index a1233f2..030e18f 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -48,6 +48,45 @@ type Settings struct { // addresses that don't belong on the interface, for networks where // another DHCP server shares the segment. Off by default. NotAuthoritative bool `json:"not_authoritative"` + // AddressOrder is which never-used pool address a new client gets: + // sequential (the lowest, the default), reverse (the highest) or + // random. Subnets and pools can set their own. + AddressOrder string `json:"address_order,omitempty"` +} + +// Address orders: which never-used pool address a new client gets. When +// every free address has been used before, the one free the longest is +// given out, whatever the order. +const ( + OrderSequential = "sequential" + OrderReverse = "reverse" + OrderRandom = "random" +) + +// Pool orders: which of a subnet's pools a new address comes from. +const ( + // PoolsListed tries the pools in the order they are listed. + PoolsListed = "listed" + // PoolsRandom picks a pool at random, weighted by its never-used + // addresses, so addresses spread over the whole subnet. + PoolsRandom = "random" +) + +// AddressOrders are the address orders, for messages and help. +var AddressOrders = []string{OrderSequential, OrderReverse, OrderRandom} + +// ValidAddressOrder reports whether s is an address order ("" too: the +// default or inherited one). +func ValidAddressOrder(s string) bool { + return s == "" || s == OrderSequential || s == OrderReverse || s == OrderRandom +} + +// EffectiveAddressOrder is the settings' address order with its default. +func (s Settings) EffectiveAddressOrder() string { + if s.AddressOrder == "" { + return OrderSequential + } + return s.AddressOrder } // DefaultSettings are the settings of a new database. @@ -101,6 +140,12 @@ type Subnet struct { Interface string `json:"interface"` Prefix netip.Prefix `json:"prefix"` Pools []Pool `json:"pools"` + // PoolOrder is which pool a new address comes from: listed (the + // default, "") or random. + PoolOrder string `json:"pool_order,omitempty"` + // AddressOrder overrides the settings' address order inside the + // subnet's pools ("": inherit); a pool can override it in turn. + AddressOrder string `json:"address_order,omitempty"` // LeaseTime overrides the settings' lease time (0: inherit). LeaseTime int `json:"lease_time"` Options []OptionValue `json:"options"` @@ -122,9 +167,25 @@ func (s *Subnet) Label() string { type Pool struct { Start netip.Addr `json:"start"` End netip.Addr `json:"end"` + // Order overrides the subnet's address order in this pool ("": + // inherit). + Order string `json:"order,omitempty"` } -func (p Pool) String() string { return p.Start.String() + "-" + p.End.String() } +// String is the pool as ParsePool reads it: FIRST-LAST, with :ORDER when +// it has its own. +func (p Pool) String() string { + if p.Order != "" { + return p.Range() + ":" + p.Order + } + return p.Range() +} + +// Range is FIRST-LAST. +func (p Pool) Range() string { return p.Start.String() + "-" + p.End.String() } + +// SameRange reports whether two pools cover the same addresses. +func (p Pool) SameRange(o Pool) bool { return p.Start == o.Start && p.End == o.End } // Contains reports whether a is in the pool. func (p Pool) Contains(a netip.Addr) bool { @@ -140,9 +201,21 @@ func (p Pool) Size() int { } // ParsePool reads "10.0.10.100-10.0.10.199" (spaces around the dash are -// fine) or a prefix, "10.0.10.128/25". +// fine) or a prefix, "10.0.10.128/25", optionally followed by the pool's +// own address order: "10.0.10.100-10.0.10.199:random". func ParsePool(s string) (Pool, error) { s = strings.TrimSpace(s) + if r, order, ok := strings.Cut(s, ":"); ok { + order = strings.ToLower(strings.TrimSpace(order)) + if order == "default" { + order = "" // the subnet's + } else if order == "" || !ValidAddressOrder(order) { + return Pool{}, fmt.Errorf("pool %q: the order after the colon is %s or default (the subnet's)", s, strings.Join(AddressOrders, ", ")) + } + p, err := ParsePool(r) + p.Order = order + return p, err + } if a, b, ok := strings.Cut(s, "-"); ok { start, err1 := netip.ParseAddr(strings.TrimSpace(a)) end, err2 := netip.ParseAddr(strings.TrimSpace(b)) diff --git a/internal/model/config_test.go b/internal/model/config_test.go new file mode 100644 index 0000000..b66c5f7 --- /dev/null +++ b/internal/model/config_test.go @@ -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) + } + } +} diff --git a/internal/server/h_config.go b/internal/server/h_config.go index 213e134..7a8e69a 100644 --- a/internal/server/h_config.go +++ b/internal/server/h_config.go @@ -118,6 +118,9 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth. if in.NotAuthoritative != nil { set.NotAuthoritative = *in.NotAuthoritative } + if in.AddressOrder != nil { + set.AddressOrder = orderInput(*in.AddressOrder) + } return nil }, func(st *model.State) any { return st.Settings }) } @@ -194,6 +197,16 @@ func (s *Server) getSubnet(w http.ResponseWriter, r *http.Request, p *auth.Princ return nil } +// orderInput reads an address or pool order: "default" is "" (inherited, +// or the default); validation checks the rest. +func orderInput(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "default" { + return "" + } + return s +} + // applySubnetInput sets the given fields. func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error { if in.Name != nil { @@ -219,6 +232,12 @@ func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error { sn.Pools = append(sn.Pools, pool) } } + if in.PoolOrder != nil { + sn.PoolOrder = orderInput(*in.PoolOrder) + } + if in.AddressOrder != nil { + sn.AddressOrder = orderInput(*in.AddressOrder) + } if in.LeaseTime != nil { sn.LeaseTime = *in.LeaseTime } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index d6cef41..b05844b 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -405,3 +405,32 @@ func TestInterfacesExclude(t *testing.T) { 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) + } +} diff --git a/internal/store/state.go b/internal/store/state.go index 05b0c64..4c16648 100644 --- a/internal/store/state.go +++ b/internal/store/state.go @@ -25,7 +25,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) { if st.Settings.Options == nil { st.Settings.Options = []model.OptionValue{} } - rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, lease_time, options, disabled, comment FROM subnets ORDER BY id`) + rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, disabled, comment FROM subnets ORDER BY id`) if err != nil { return nil, err } @@ -34,7 +34,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) { sn := &model.Subnet{} var prefix, pools, opts string var disabled int - if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.LeaseTime, &opts, &disabled, &sn.Comment); err != nil { + if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.PoolOrder, &sn.AddressOrder, &sn.LeaseTime, &opts, &disabled, &sn.Comment); err != nil { return nil, err } sn.Prefix, _ = netip.ParsePrefix(prefix) @@ -146,10 +146,10 @@ func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error { if err := json.Unmarshal(c.After, &sn); err != nil { return err } - _, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, lease_time, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?) + _, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, interface = excluded.interface, prefix = excluded.prefix, pools = excluded.pools, - lease_time = excluded.lease_time, options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`, - sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment) + pool_order = excluded.pool_order, address_order = excluded.address_order, lease_time = excluded.lease_time, options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`, + sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.PoolOrder, sn.AddressOrder, sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment) return err case model.KindReservation: if c.After == nil { diff --git a/internal/store/store.go b/internal/store/store.go index f129332..fec26d5 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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. ` 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 ''; `, } diff --git a/internal/validate/validate.go b/internal/validate/validate.go index dc92e48..1a3c10a 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -84,6 +84,9 @@ func (c *checker) settings(s model.Settings) { if s.DeclineHold != 0 && (s.DeclineHold < 60 || s.DeclineHold > 30*24*3600) { c.add(Error, "decline_hold_range", "settings", "the decline hold is %d seconds; use 1 minute to 30 days", s.DeclineHold) } + if !model.ValidAddressOrder(s.AddressOrder) { + c.add(Error, "address_order", "settings", "the address order %q is not one of %s", s.AddressOrder, strings.Join(model.AddressOrders, ", ")) + } c.options(s.Options, "settings") } @@ -173,22 +176,31 @@ func (c *checker) pools(s *model.Subnet, obj string) { if len(s.Pools) == 0 && !s.Disabled { c.add(Warning, "subnet_no_pools", obj, "the subnet has no pools: only clients with a reservation get an address") } + if s.PoolOrder != "" && s.PoolOrder != model.PoolsListed && s.PoolOrder != model.PoolsRandom { + c.add(Error, "pool_order", obj, "the pool order %q is not listed or random", s.PoolOrder) + } + if !model.ValidAddressOrder(s.AddressOrder) { + c.add(Error, "address_order", obj, "the address order %q is not one of %s", s.AddressOrder, strings.Join(model.AddressOrders, ", ")) + } network, broadcast := s.Prefix.Addr(), model.Last(s.Prefix) for i, p := range s.Pools { + if !model.ValidAddressOrder(p.Order) { + c.add(Error, "address_order", obj, "pool %s: the address order %q is not one of %s", p.Range(), p.Order, strings.Join(model.AddressOrders, ", ")) + } if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) { - c.add(Error, "pool_invalid", obj, "pool %s is not a range of IPv4 addresses", p) + c.add(Error, "pool_invalid", obj, "pool %s is not a range of IPv4 addresses", p.Range()) continue } if !s.Prefix.Contains(p.Start) || !s.Prefix.Contains(p.End) { - c.add(Error, "pool_outside_subnet", obj, "pool %s is not inside %s", p, s.Prefix) + c.add(Error, "pool_outside_subnet", obj, "pool %s is not inside %s", p.Range(), s.Prefix) continue } if p.Contains(network) || p.Contains(broadcast) { - c.add(Error, "pool_network_address", obj, "pool %s includes the network address %s or the broadcast address %s; start it at %s and end it at %s at most", p, network, broadcast, network.Next(), broadcast.Prev()) + c.add(Error, "pool_network_address", obj, "pool %s includes the network address %s or the broadcast address %s; start it at %s and end it at %s at most", p.Range(), network, broadcast, network.Next(), broadcast.Prev()) } for _, o := range s.Pools[:i] { if o.Start.Is4() && o.End.Is4() && !(p.End.Less(o.Start) || o.End.Less(p.Start)) { - c.add(Error, "pool_overlap", obj, "pools %s and %s overlap", o, p) + c.add(Error, "pool_overlap", obj, "pools %s and %s overlap", o.Range(), p.Range()) } } } @@ -221,7 +233,7 @@ func (c *checker) routers(s *model.Subnet, global []model.OptionValue, obj strin } for _, p := range s.Pools { if p.Contains(a) { - c.add(Warning, "router_in_pool", obj, "the router %s is inside pool %s; it is never handed out while it answers pings, but take it out of the pool", a, p) + c.add(Warning, "router_in_pool", obj, "the router %s is inside pool %s; it is never handed out while it answers pings, but take it out of the pool", a, p.Range()) } } } @@ -242,7 +254,7 @@ func (c *checker) hostInterface(s *model.Subnet, env *Env, obj string) { if s.Prefix.Contains(a.Addr()) { for _, p := range s.Pools { if p.Contains(a.Addr()) { - c.add(Warning, "pool_includes_server", obj, "pool %s includes this server's own address %s; it is never handed out, but take it out of the pool", p, a.Addr()) + c.add(Warning, "pool_includes_server", obj, "pool %s includes this server's own address %s; it is never handed out, but take it out of the pool", p.Range(), a.Addr()) } } return diff --git a/internal/web/static/assets/pages/settings.js b/internal/web/static/assets/pages/settings.js index 9c8eac3..4473e19 100644 --- a/internal/web/static/assets/pages/settings.js +++ b/internal/web/static/assets/pages/settings.js @@ -1,8 +1,8 @@ // Settings: the default lease time, conflict checks, authority, and the // options every subnet gets. -import { h, mount, input, field, checkbox, errorBox, readOnly } from "../ui.js"; +import { h, mount, input, field, checkbox, errorBox, readOnly, select } from "../ui.js"; import { get, change, can } from "../api.js"; -import { optionsEditor, fmtSeconds, parseSeconds } from "../widgets.js"; +import { optionsEditor, fmtSeconds, parseSeconds, ADDRESS_ORDERS } from "../widgets.js"; import { rerender } from "../app.js"; export async function render(el) { @@ -14,6 +14,7 @@ export async function render(el) { timeout.style.width = "7em"; const hold = input({ value: fmtSeconds(s.decline_hold || 86400) }); const auth = checkbox("Authoritative: refuse (NAK) requests for addresses that don't belong on the interface", !s.not_authoritative); + const order = select(ADDRESS_ORDERS, s.address_order || "sequential"); const opts = await optionsEditor(s.options); const err = h("div"); const save = async (dry) => { @@ -22,7 +23,7 @@ export async function render(el) { if (lt === null || dh === null) { mount(err, h("div", { class: "msg err" }, "Write times as 12h, 1d or 30m.")); return; } try { const r = await change("PATCH", "/settings", { lease_time: lt, ping_check: ping.input.checked, ping_timeout_ms: parseInt(timeout.value, 10) || 0, - decline_hold: dh, not_authoritative: !auth.input.checked, options: opts.value() }, { query: dry ? { dry_run: 1 } : undefined }); + decline_hold: dh, not_authoritative: !auth.input.checked, address_order: order.value, options: opts.value() }, { query: dry ? { dry_run: 1 } : undefined }); if (!dry && r.applied) rerender(); } catch (e) { mount(err, errorBox(e)); } }; @@ -35,6 +36,7 @@ export async function render(el) { field("Default lease time", lease, "How long clients keep an address unless a subnet says otherwise (12h, 1d). They renew halfway."), field("Options for every subnet", opts, "Usually domain-name-servers and domain-name; routers belong to each subnet."), field("Conflict check", h("div", null, ping, h("div", { class: "small" }, "wait ", timeout, " ms for an answer"))), + field("Address order", order, "Which address of a pool a new client gets: sequential (the lowest), reverse (the highest) or random. Only addresses never handed out count; when there are none left, the one free the longest. Subnets and pools can set their own."), field("Hold back for", hold, "How long an address a client declined (or one that answered a ping) isn't handed out."), field("Authority", auth, "Turn this off only if another DHCP server serves the same network.")), err, diff --git a/internal/web/static/assets/pages/subnet.js b/internal/web/static/assets/pages/subnet.js index 736ab4f..cc7a4b5 100644 --- a/internal/web/static/assets/pages/subnet.js +++ b/internal/web/static/assets/pages/subnet.js @@ -1,7 +1,7 @@ // A subnet's page: its leases, its reservations and its settings. import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox } from "../ui.js"; import { get, change, can } from "../api.js"; -import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js"; +import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText, orderText } from "../widgets.js"; import { leasesTable } from "./leases.js"; import { reservationsTable, reservationDialog } from "./reservations.js"; import { rerender } from "../app.js"; @@ -19,7 +19,8 @@ export async function render(el, params) { h("div", { class: "grow" }, h("h1", null, subnetLabel(s), " ", s.disabled ? badge("disabled", "off") : null), h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ", - s.pools.length ? h("span", { class: "mono" }, s.pools.map((p) => p.start + "-" + p.end).join(", ")) : "none", + s.pools.length ? h("span", { class: "mono" }, s.pools.map(poolText).join(", ")) : "none", + orderText(s) ? " · " + orderText(s) : "", " · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")), h("div", { class: "subnet-usage" }, usageBar(s.usage)), can("config", true) ? h("div", { class: "actions" }, h("button", { diff --git a/internal/web/static/assets/pages/subnets.js b/internal/web/static/assets/pages/subnets.js index d40b1f3..8e9b25d 100644 --- a/internal/web/static/assets/pages/subnets.js +++ b/internal/web/static/assets/pages/subnets.js @@ -1,12 +1,12 @@ // The subnets, and the New subnet dialog. import { h, mount, table, modal, errorBox, badge } from "../ui.js"; import { get, change, can } from "../api.js"; -import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js"; +import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText } from "../widgets.js"; import { toggleSubnet } from "./subnet.js"; export async function render(el, params, query) { const subnets = await get("/subnets"); - const poolsText = (s) => s.pools.length ? s.pools.map((p) => p.start + "-" + p.end).join(", ") : "–"; + const poolsText = (s) => s.pools.length ? s.pools.map(poolText).join(", ") : "–"; mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Subnets"), diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 8cbc26b..660ec8e 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -342,6 +342,7 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent); .pool-rows { display: flex; flex-direction: column; gap: 6px; } .pool-row { display: flex; gap: 6px; align-items: center; } .pool-row input { font-family: var(--mono); width: 18em; } +.pool-row select { width: auto; } /* Activity: one line per message. */ table.activity td { white-space: nowrap; } table.activity td.detail { white-space: normal; color: var(--muted); } diff --git a/internal/web/static/assets/widgets.js b/internal/web/static/assets/widgets.js index fb52ca5..111ca1e 100644 --- a/internal/web/static/assets/widgets.js +++ b/internal/web/static/assets/widgets.js @@ -114,25 +114,51 @@ const examples = { // ---- pools // poolsEditor edits a subnet's pools; value() is ["first-last", ...]. +// Each pool has its own address order, or the subnet's; ↑ moves a pool up +// (with pools in the order listed, the first is used first). export function poolsEditor(pools, placeholder) { const rows = h("div", { class: "pool-rows" }); const box = h("div", null, rows); - const list = []; - const addRow = (v) => { - const inp = input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" }); - const row = h("div", { class: "pool-row" }, inp, - h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { row.remove(); list.splice(list.indexOf(inp), 1); } }, "✕")); - list.push(inp); - rows.append(row); - return inp; + let list = []; + const addRow = (v, order) => { + const it = { inp: input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" }) }; + it.sel = select([["", "subnet's order"], ...ADDRESS_ORDERS.map((o) => [o, o])], order || "", { title: "The order addresses of this pool are handed out in" }); + it.row = h("div", { class: "pool-row" }, it.inp, it.sel, + h("button", { type: "button", class: "small", title: "Move this pool up: it is used before the ones below", onclick: () => { + const i = list.indexOf(it); + if (i > 0) { list.splice(i, 1); list.splice(i - 1, 0, it); rows.insertBefore(it.row, list[i].row); } + } }, "↑"), + h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { it.row.remove(); list = list.filter((x) => x !== it); } }, "✕")); + list.push(it); + rows.append(it.row); + return it.inp; }; - for (const p of pools || []) addRow(p.start + "-" + p.end); + for (const p of pools || []) addRow(p.start + "-" + p.end, p.order); box.append(h("button", { type: "button", class: "small", onclick: () => addRow("").focus() }, "+ Add pool")); - box.value = () => list.map((i) => i.value.trim()).filter((s) => s !== ""); - box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].value) list[0].value = v; }; + box.value = () => list.map((it) => { + const v = it.inp.value.trim(); + return v && it.sel.value && !v.includes(":") ? v + ":" + it.sel.value : v; + }).filter((s) => s !== ""); + box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].inp.value) list[0].inp.value = v; }; return box; } +export const ADDRESS_ORDERS = ["sequential", "reverse", "random"]; + +// poolText shows a pool, with its own address order. +export function poolText(p) { + return p.start + "-" + p.end + (p.order ? " (" + p.order + ")" : ""); +} + +// orderText says how a subnet hands out new addresses, or "" for the +// defaults. +export function orderText(s) { + const parts = []; + if (s.pool_order === "random") parts.push("a pool at random"); + if (s.address_order) parts.push(s.address_order + " addresses"); + return parts.join(", "); +} + // ---- the subnet form // v4 helpers for suggestions. @@ -163,6 +189,8 @@ export async function subnetForm(sn, settings) { const ifaceOther = input({ value: sn && !ifaceNames.includes(sn.interface) ? sn.interface : "", placeholder: "eth0.30 (one that comes up later)" }); const prefix = input({ value: sn ? sn.prefix : "", placeholder: "10.0.10.0/24", class: "mono" }); const pools = poolsEditor(sn ? sn.pools : []); + const poolOrder = select([["", "in the order listed"], ["random", "at random (weighted by free addresses)"]], sn ? sn.pool_order || "" : ""); + const addrOrder = select([["", "the settings' (" + (settings.address_order || "sequential") + ")"], ...ADDRESS_ORDERS.map((o) => [o, o])], sn ? sn.address_order || "" : ""); const lease = input({ value: sn ? fmtSeconds(sn.lease_time) : "", placeholder: "the default: " + fmtSeconds(settings.lease_time || 43200) }); const opts = await optionsEditor(sn ? sn.options : [], settings.options); const comment = input({ value: sn ? sn.comment : "" }); @@ -192,6 +220,8 @@ export async function subnetForm(sn, settings) { field("Interface", h("div", null, ifaceSel, " ", ifaceOther)), ifaceHint, field("Network", prefix, "The subnet's prefix; the mask clients get comes from it."), field("Pools", pools, "Ranges handed out to any client: FIRST-LAST, or a prefix. Reserved addresses inside a pool are never handed out to others."), + field("Pool order", poolOrder, "Which pool a new client's address comes from."), + field("Address order", addrOrder, "Which address of a pool: sequential (the lowest), reverse (the highest) or random. A pool can set its own. New clients get an address never handed out before; when there are none, the one free the longest. Returning clients keep theirs."), field("Lease time", lease, "12h, 1d, 30m; empty for the default."), field("Options", opts), field("Comment", comment), @@ -199,7 +229,7 @@ export async function subnetForm(sn, settings) { grid.values = () => { const lt = parseSeconds(lease.value); if (lt === null) throw new Error("Lease time: write it as 12h, 1d or 30m"); - const v = { name: name.value.trim(), interface: ifaceValue(), prefix: prefix.value.trim(), pools: pools.value(), lease_time: lt, options: opts.value(), comment: comment.value }; + const v = { name: name.value.trim(), interface: ifaceValue(), prefix: prefix.value.trim(), pools: pools.value(), pool_order: poolOrder.value || "listed", address_order: addrOrder.value || "default", lease_time: lt, options: opts.value(), comment: comment.value }; if (sn) v.disabled = disabled.input.checked; return v; }; diff --git a/scripts/e2e.sh b/scripts/e2e.sh index de741fc..ae95cdc 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -220,6 +220,20 @@ run "delete a lease" 0 S lease delete $NA.101 --yes run "audit log" 0 S audit --action lease. contains "lease.delete" +# ---- address order + +run "reverse address order" 0 S subnet update iot --address-order reverse +run "the subnet shows its order" 0 S subnet show iot +contains "addresses reverse" +run "a new client gets the highest never-used address" 0 udhcpc b 02:00:00:00:00:40 +contains "BOUND ip=$NB.110 " +run "a pool's own order wins" 0 S subnet update iot --add-pool $NB.100-$NB.110:sequential +run "the pool shows it" 0 S subnet show iot +contains "$NB.100-$NB.110:sequential" +run "now the lowest never-used one" 0 udhcpc b 02:00:00:00:00:41 +contains "BOUND ip=$NB.10[0-8] " +run "an unknown order is refused" 1 S subnet update iot --add-pool $NB.100-$NB.110:lowest + # ---- backup run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup