Let the Reserve button pick another address of the lease's subnet

The Reserve dialog fills in the lease's address but lets it be changed;
POST /leases/{ip}/reserve takes an optional ip, steward lease reserve
takes --ip. The address must be in the lease's subnet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-25 00:29:07 +01:00
1 parent c7a64773a7
commit 1bc9f9f63a
8 files changed
+49 -12

No files matched your search

+4
View File
@@ -8,6 +8,10 @@ All notable changes to Steward are documented here. The format follows
### Added ### 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 - **Failed logins in the journal.** A failed login and a refused API
token are logged as well as audited, with the address first token are logged as well as audited, with the address first
(`msg="login failed" ip=… user=…`, `msg="token refused" ip=…`), for (`msg="login failed" ip=… user=…`, `msg="token refused" ip=…`), for
+9 -2
View File
@@ -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") del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del) cmd.AddCommand(del)
var hostname, comment, by string var hostname, comment, by, ip string
reserve := &cobra.Command{ reserve := &cobra.Command{
Use: "reserve IP", Use: "reserve IP",
Short: "Make a lease's address its client's reservation", 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, 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 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), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient() c, err := newClient()
@@ -153,6 +156,9 @@ its client ID (option 61): either one gets it the address. --by mac or
if by != "" { if by != "" {
body["by"] = by body["by"] = by
} }
if ip != "" {
body["ip"] = ip
}
var resp api.MutationResponse var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil { if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil {
return err 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(&hostname, "hostname", "", "the reservation's host name")
reserve.Flags().StringVar(&comment, "comment", "", "a comment") 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)") reserve.Flags().StringVar(&by, "by", "", "know the client by mac, client-id or both (the default)")
cmd.AddCommand(reserve) cmd.AddCommand(reserve)
return cmd return cmd
+1 -1
View File
@@ -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[?subnet=&state=&interface=&q=]` | every lease, with its state now (`active`, `expired`, `released`, `declined`), `subnet` and its reservation, and `last_seen` when the client last answered a ping (left out if it never has); `q` searches address, MAC and host name |
| `GET /leases/{ip}` | one | | `GET /leases/{ip}` | one |
| `DELETE /leases/{ip}` | forget it (audited) | | `DELETE /leases/{ip}` | forget it (audited) |
| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment", "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[?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 | | `GET /events/{id}` | one message with `fields` (header fields that are set) and `options`, each `{"code", "name", "value"}` in words |
+1 -1
View File
@@ -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 list [--subnet S] [--state STATE] [--interface I] [--search TEXT] [-a]` | active and held-back leases; `-a` all |
| `lease show IP` | one lease | | `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 | | `lease delete IP [-y]` | free the address at once |
### activity ### activity
+1
View File
@@ -56,6 +56,7 @@ A reservation fixes a client's address:
```sh ```sh
steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas 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 # 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 * **The client** is known by its MAC address, its client identifier
+21 -3
View File
@@ -114,7 +114,8 @@ func (s *Server) deleteLease(w http.ResponseWriter, r *http.Request, p *auth.Pri
return nil 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 { func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
ip, err := leaseIP(r) ip, err := leaseIP(r)
if err != nil { 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 // By is how the reservation knows the client: mac, client_id or
// both (the default when the client sent a client ID). // both (the default when the client sent a client ID).
By string `json:"by,omitempty"` 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 { if err := decodeOptional(r, &in); err != nil {
return err return err
@@ -161,10 +164,25 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr
} else if !validate.ValidHostname(host) { } else if !validate.ValidHostname(host) {
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 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() 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 return nil
}, func(st *model.State) any { }, func(st *model.State) any {
if res := st.ReservationByID(id); res != nil { if res := st.ReservationByID(id); res != nil {
+6 -2
View File
@@ -483,12 +483,16 @@ func TestReserveByClientID(t *testing.T) {
if rv.MAC != "aa:00:00:00:00:01" || rv.ClientID != "ff01" { if rv.MAC != "aa:00:00:00:00:01" || rv.ClientID != "ff01" {
t.Errorf("both: %+v", rv) 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) t.Fatalf("reserve by id: %d", code)
} }
rv = api.Reservation{} rv = api.Reservation{}
json.Unmarshal(mr.Object, &rv) 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) t.Errorf("by client id: %+v", rv)
} }
var eb api.ErrorBody var eb api.ErrorBody
+6 -3
View File
@@ -36,18 +36,21 @@ function stateBadge(st) {
} }
async function reserve(l, done) { async function reserve(l, done) {
const ip = input({ value: l.ip, class: "mono" });
const host = input({ value: l.hostname || "", placeholder: "host name (optional)" }); 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 by = select([["both", "MAC address or client ID (either one)"], ["mac", "MAC address only"], ["client_id", "client ID only"]], "both");
const err = h("div"); const err = h("div");
await modal("Reserve " + l.ip, h("div", null, await modal("Reserve " + l.ip, h("div", null,
h("p", null, (l.mac || "The client") + " keeps " + l.ip + " from now on."), h("p", null, (l.mac || "The client") + " keeps this address from now on."),
h("div", { class: "form-grid" }, h("label", null, "Host name"), host, 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"), 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), [ 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: "Cancel", value: null },
{ label: "Reserve", kind: "primary", onclick: async () => { { label: "Reserve", kind: "primary", onclick: async () => {
try { 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(); done();
return true; return true;
} catch (e) { } catch (e) {