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
- **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
+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")
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
+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/{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 |
+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 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
+1
View File
@@ -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
+21 -3
View File
@@ -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 {
+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" {
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
+6 -3
View File
@@ -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) {