diff --git a/CHANGELOG.md b/CHANGELOG.md index 6880492..f532344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to Steward are documented here. The format follows ### Added +- **Reserve another address from a lease.** The Reserve dialog on the + Leases page fills in the lease's address but lets you change it, to + any address of the same subnet (`steward lease reserve IP --ip ADDR`, + `"ip"` in the API). The client moves there when it next renews. - **Failed logins in the journal.** A failed login and a refused API token are logged as well as audited, with the address first (`msg="login failed" ip=… user=…`, `msg="token refused" ip=…`), for diff --git a/cmd/steward/leases.go b/cmd/steward/leases.go index fd7e280..7f89e1a 100644 --- a/cmd/steward/leases.go +++ b/cmd/steward/leases.go @@ -126,7 +126,7 @@ gets a NAK when it next renews, and then asks for a new address.`, } del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask") cmd.AddCommand(del) - var hostname, comment, by string + var hostname, comment, by, ip string reserve := &cobra.Command{ Use: "reserve IP", Short: "Make a lease's address its client's reservation", @@ -136,7 +136,10 @@ then on. The host name the client sent becomes the reservation's, unless The reservation knows the client by its MAC address and, if it sent one, its client ID (option 61): either one gets it the address. --by mac or ---by client-id uses just one.`, +--by client-id uses just one. + +--ip reserves another address of the same subnet instead; the client +moves there when it next renews.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() @@ -153,6 +156,9 @@ its client ID (option 61): either one gets it the address. --by mac or if by != "" { body["by"] = by } + if ip != "" { + body["ip"] = ip + } var resp api.MutationResponse if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil { return err @@ -162,6 +168,7 @@ its client ID (option 61): either one gets it the address. --by mac or } reserve.Flags().StringVar(&hostname, "hostname", "", "the reservation's host name") reserve.Flags().StringVar(&comment, "comment", "", "a comment") + reserve.Flags().StringVar(&ip, "ip", "", "reserve this address of the lease's subnet instead") reserve.Flags().StringVar(&by, "by", "", "know the client by mac, client-id or both (the default)") cmd.AddCommand(reserve) return cmd diff --git a/docs/api.md b/docs/api.md index 979dec5..69a1715 100644 --- a/docs/api.md +++ b/docs/api.md @@ -125,7 +125,7 @@ out. `lease_time` 0 means the settings'. | `GET /leases[?subnet=&state=&interface=&q=]` | every lease, with its state now (`active`, `expired`, `released`, `declined`), `subnet` and its reservation, and `last_seen` when the client last answered a ping (left out if it never has); `q` searches address, MAC and host name | | `GET /leases/{ip}` | one | | `DELETE /leases/{ip}` | forget it (audited) | -| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment", "by"}`, `by` being `mac`, `client_id` or `both` (the default) | +| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"ip", "hostname", "comment", "by"}`, `ip` another address of the lease's subnet to reserve instead, `by` being `mac`, `client_id` or `both` (the default) | | `GET /events[?mac=&subnet=&interface=&limit=&details=1]` | the latest DHCP messages, newest first (the last 1000 are kept in memory), each with an `id`, `direction` (`in` from the client, `out` from Steward) and `client_id`. `subnet` (ID, name or prefix) keeps messages for its addresses, and those without an address on its interface. `details=1` adds `fields` and `options` | | `GET /events/{id}` | one message with `fields` (header fields that are set) and `options`, each `{"code", "name", "value"}` in words | diff --git a/docs/cli.md b/docs/cli.md index 4be6a79..ee042f6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -100,7 +100,7 @@ See [subnets.md](subnets.md#access-allow-and-deny-lists). |---|---| | `lease list [--subnet S] [--state STATE] [--interface I] [--search TEXT] [-a]` | active and held-back leases; `-a` all | | `lease show IP` | one lease | -| `lease reserve IP [--hostname H] [--by mac\|client-id\|both]` | make the address its client's reservation, by its MAC address and client ID (either matches) unless `--by` says one | +| `lease reserve IP [--ip ADDR] [--hostname H] [--by mac\|client-id\|both]` | make the address its client's reservation, by its MAC address and client ID (either matches) unless `--by` says one; `--ip` reserves another address of the same subnet instead | | `lease delete IP [-y]` | free the address at once | ### activity diff --git a/docs/subnets.md b/docs/subnets.md index 87f04ae..1829da7 100644 --- a/docs/subnets.md +++ b/docs/subnets.md @@ -56,6 +56,7 @@ A reservation fixes a client's address: ```sh steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas steward lease reserve 10.0.10.57 # the address a client has now +steward lease reserve 10.0.10.57 --ip 10.0.10.21 # its client, another address ``` * **The client** is known by its MAC address, its client identifier diff --git a/internal/server/h_leases.go b/internal/server/h_leases.go index 8b9c83b..1aaef2a 100644 --- a/internal/server/h_leases.go +++ b/internal/server/h_leases.go @@ -114,7 +114,8 @@ func (s *Server) deleteLease(w http.ResponseWriter, r *http.Request, p *auth.Pri return nil } -// reserveLease makes a lease's address the client's reservation. +// reserveLease makes a lease's address (or another in its subnet) the +// client's reservation. func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { ip, err := leaseIP(r) if err != nil { @@ -126,6 +127,8 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr // By is how the reservation knows the client: mac, client_id or // both (the default when the client sent a client ID). By string `json:"by,omitempty"` + // IP reserves another address of the lease's subnet instead. + IP string `json:"ip,omitempty"` } if err := decodeOptional(r, &in); err != nil { return err @@ -161,10 +164,25 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr } else if !validate.ValidHostname(host) { host = "" } + want := ip + if in.IP != "" { + if want, err = netip.ParseAddr(strings.TrimSpace(in.IP)); err != nil || !want.Is4() { + return badRequest("ip %q: not an IPv4 address", in.IP) + } + } var id int64 - return s.change(w, r, p, "reserve "+ip.String()+" for "+who, func(st *model.State, newID func() int64) error { + return s.change(w, r, p, "reserve "+want.String()+" for "+who, func(st *model.State, newID func() int64) error { + if want != ip { + if sub := st.SubnetFor(ip); sub == nil || !sub.Prefix.Contains(want) { + where := "the lease's subnet" + if sub != nil { + where = sub.Prefix.String() + } + return badRequest("%s isn't in %s", want, where) + } + } id = newID() - st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: mac, ClientID: cid, IP: ip, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment}) + st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: mac, ClientID: cid, IP: want, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment}) return nil }, func(st *model.State) any { if res := st.ReservationByID(id); res != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 3a26274..c270921 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -483,12 +483,16 @@ func TestReserveByClientID(t *testing.T) { if rv.MAC != "aa:00:00:00:00:01" || rv.ClientID != "ff01" { t.Errorf("both: %+v", rv) } - if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"by": "client_id"}, &mr); code != 201 { + var eb0 api.ErrorBody + if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"ip": "10.0.10.30"}, &eb0); code != 400 { + t.Errorf("reserve outside the subnet: %d %+v", code, eb0) + } + if code := e.call(e.token, "POST", "/leases/"+b.String()+"/reserve", map[string]any{"ip": "10.0.20.200", "by": "client_id"}, &mr); code != 201 { t.Fatalf("reserve by id: %d", code) } rv = api.Reservation{} json.Unmarshal(mr.Object, &rv) - if rv.MAC != "" || rv.ClientID != "ff02" { + if rv.MAC != "" || rv.ClientID != "ff02" || rv.IP.String() != "10.0.20.200" { t.Errorf("by client id: %+v", rv) } var eb api.ErrorBody diff --git a/internal/web/static/assets/pages/leases.js b/internal/web/static/assets/pages/leases.js index 341b99a..82e1aaa 100644 --- a/internal/web/static/assets/pages/leases.js +++ b/internal/web/static/assets/pages/leases.js @@ -36,18 +36,21 @@ function stateBadge(st) { } async function reserve(l, done) { + const ip = input({ value: l.ip, class: "mono" }); const host = input({ value: l.hostname || "", placeholder: "host name (optional)" }); const by = select([["both", "MAC address or client ID (either one)"], ["mac", "MAC address only"], ["client_id", "client ID only"]], "both"); const err = h("div"); await modal("Reserve " + l.ip, h("div", null, - h("p", null, (l.mac || "The client") + " keeps " + l.ip + " from now on."), - h("div", { class: "form-grid" }, h("label", null, "Host name"), host, + h("p", null, (l.mac || "The client") + " keeps this address from now on."), + h("div", { class: "form-grid" }, h("label", null, "Address"), ip, h("label"), + h("div", { class: "help" }, "Another address of the same subnet moves the client there when it next renews."), + h("label", null, "Host name"), host, l.client_id && l.mac ? [h("label", null, "Know it by"), by, h("label"), h("div", { class: "help" }, "It sent the client ID ", h("span", { class: "mono" }, l.client_id), ". With either one, it gets the address whichever system it boots.")] : null), err), [ { label: "Cancel", value: null }, { label: "Reserve", kind: "primary", onclick: async () => { try { - await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim(), by: l.client_id && l.mac ? by.value : undefined }); + await change("POST", "/leases/" + l.ip + "/reserve", { ip: ip.value.trim(), hostname: host.value.trim(), by: l.client_id && l.mac ? by.value : undefined }); done(); return true; } catch (e) {