From 4fd72998ecc416435e63556031472c4fd32686c9 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Sun, 18 Oct 2026 20:26:24 +0100 Subject: [PATCH] Reservation lease time; reservations by MAC address or client ID - A reservation can have its own lease time (--lease-time, the reservation dialog), over the subnet's. Schema migration 6. - A reservation with a MAC address and a client ID matches either one (a dual-boot machine sends two client IDs from one MAC); before, it was found by MAC and then refused. A lease of the reserved address by the client's other identity counts as its own. - The Leases page shows a client's client ID; reserving a lease uses both, or one with --by / the dialog's choice. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++ cmd/steward/leases.go | 12 +++++- cmd/steward/subnets.go | 40 ++++++++++++++---- docs/api.md | 4 +- docs/cli.md | 6 +-- docs/subnets.md | 17 +++++--- internal/api/types.go | 6 ++- internal/dhcpd/config.go | 10 ++--- internal/dhcpd/handle.go | 30 +++++++++---- internal/dhcpd/handle_test.go | 36 +++++++++++++++- internal/model/config.go | 6 ++- internal/server/h_config.go | 3 ++ internal/server/h_leases.go | 30 +++++++++++-- internal/server/server_test.go | 42 +++++++++++++++++++ internal/store/state.go | 10 ++--- internal/store/store.go | 4 ++ internal/validate/validate.go | 21 ++++++---- internal/web/static/assets/pages/leases.js | 14 ++++--- .../web/static/assets/pages/reservations.js | 14 ++++--- scripts/e2e.sh | 6 +++ 20 files changed, 252 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6d4509..1c34ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,12 @@ and a CLI, built the way Jarl is. 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. +- **A reservation's own lease time** (`--lease-time 7d`, the reservation + dialog), over the subnet's. +- **Reservations by MAC address and client ID together**: either one gets + the client its address (a dual-boot machine sends two client IDs from + one MAC). The Leases page shows the client ID a client sent, and + reserving a lease uses both (`steward lease reserve --by` for one). - **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 95698d6..21fb258 100644 --- a/cmd/steward/leases.go +++ b/cmd/steward/leases.go @@ -126,13 +126,17 @@ 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 string + var hostname, comment, by string reserve := &cobra.Command{ Use: "reserve IP", Short: "Make a lease's address its client's reservation", Long: `Makes the address a client has now its reservation: it keeps it from then on. The host name the client sent becomes the reservation's, unless ---hostname gives another.`, +--hostname gives another. + +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.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() @@ -146,6 +150,9 @@ then on. The host name the client sent becomes the reservation's, unless if comment != "" { body["comment"] = comment } + if by != "" { + body["by"] = by + } var resp api.MutationResponse if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil { return err @@ -155,6 +162,7 @@ then on. The host name the client sent becomes the reservation's, unless } reserve.Flags().StringVar(&hostname, "hostname", "", "the reservation's host name") reserve.Flags().StringVar(&comment, "comment", "", "a comment") + reserve.Flags().StringVar(&by, "by", "", "know the client by mac, client-id or both (the default)") cmd.AddCommand(reserve) return cmd } diff --git a/cmd/steward/subnets.go b/cmd/steward/subnets.go index 8b60564..dd6598f 100644 --- a/cmd/steward/subnets.go +++ b/cmd/steward/subnets.go @@ -385,7 +385,7 @@ func reservationCmd() *cobra.Command { for _, r := range rs { who := r.MAC if r.ClientID != "" { - who = "id " + r.ClientID + who = strings.TrimPrefix(r.MAC+" or id "+r.ClientID, " or ") } lease := "-" if r.Lease != nil { @@ -427,6 +427,11 @@ func reservationCmd() *cobra.Command { } fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname)) fmt.Fprintln(w, "Options:\t"+optionsText(r.Options)) + lt := "the subnet's" + if r.LeaseTime > 0 { + lt = fmtSeconds(r.LeaseTime) + } + fmt.Fprintf(w, "Lease time:\t%s\n", lt) if r.Lease != nil { fmt.Fprintf(w, "Lease:\tuntil %s, on %s\n", r.Lease.End.Local().Format("2006-01-02 15:04"), r.Lease.Interface) if r.Lease.LastSeen != nil { @@ -467,17 +472,18 @@ func reservationCmd() *cobra.Command { }) } - var hostname, clientID, comment, ip, mac string + var hostname, clientID, comment, ip, mac, leaseTime string var opts, unsetOpts []string add := &cobra.Command{ Use: "add IP MAC", Short: "Reserve an address for a client", - Long: `Reserves an address for a client, known by its MAC address (or by its -client identifier: give "-" for the MAC and --client-id). The address picks -the subnet. An address inside a pool is fine: it is never handed out to -anyone else. + Long: `Reserves an address for a client, known by its MAC address, its client +identifier (option 61: give "-" for the MAC and --client-id), or both: +then either one gets it the address, as for a machine that boots two +systems. The address picks the subnet. An address inside a pool is fine: +it is never handed out to anyone else. - 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 --lease-time 7d`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() @@ -497,6 +503,13 @@ anyone else. if comment != "" { in.Comment = &comment } + if leaseTime != "" { + n, err := parseSeconds(leaseTime) + if err != nil { + return err + } + in.LeaseTime = &n + } if len(opts) > 0 { o, err := optionsArg(opts) if err != nil { @@ -513,7 +526,8 @@ anyone else. } f := add.Flags() f.StringVar(&hostname, "hostname", "", "the host name to send it") - f.StringVar(&clientID, "client-id", "", "match the client identifier (hex) instead of the MAC address") + f.StringVar(&clientID, "client-id", "", "the client identifier (hex): with a MAC address, either one matches") + f.StringVar(&leaseTime, "lease-time", "", "this client's lease time (12h, 7d; default: the subnet's)") f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE for this client only") f.StringVar(&comment, "comment", "", "a comment") cmd.AddCommand(add) @@ -547,6 +561,15 @@ anyone else. if fl.Changed("comment") { in.Comment = &comment } + if fl.Changed("lease-time") { + n := 0 + if leaseTime != "default" { + if n, err = parseSeconds(leaseTime); err != nil { + return err + } + } + in.LeaseTime = &n + } if len(opts) > 0 || len(unsetOpts) > 0 { set, err := optionsArg(opts) if err != nil { @@ -570,6 +593,7 @@ anyone else. f.StringVar(&mac, "mac", "", "a new MAC address (\"\" for none)") f.StringVar(&clientID, "client-id", "", "a client identifier (\"\" for none)") f.StringVar(&hostname, "hostname", "", "a new host name") + f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 7d, or \"default\": the subnet's)") f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE") f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option") f.StringVar(&comment, "comment", "", "a comment") diff --git a/docs/api.md b/docs/api.md index 17a9166..082f3a3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -111,7 +111,7 @@ out. `lease_time` 0 means the settings'. | | | |---|---| | `GET /reservations[?subnet=REF]` | each with `subnet_id`, `subnet` and its current `lease` | -| `POST /reservations` | `{"ip", "mac", "client_id", "hostname", "options", "disabled", "comment"}`; `ip` and `mac` or `client_id` needed | +| `POST /reservations` | `{"ip", "mac", "client_id", "hostname", "options", "lease_time", "disabled", "comment"}`; `ip` and `mac` or `client_id` needed (with both, either matches); `lease_time` 0 is the subnet's | | `GET`, `PATCH`, `DELETE /reservations/{ref}` | by ID, address, or MAC address (when it has one reservation) | ### Leases and activity @@ -121,7 +121,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"}` | +| `POST /leases/{ip}/reserve` | make it a reservation; optional `{"hostname", "comment", "by"}`, `by` being `mac`, `client_id` or `both` (the default) | | `GET /events[?mac=&subnet=&interface=&limit=]` | the latest DHCP messages, newest first (the last 1000 are kept in memory). `subnet` (ID, name or prefix) keeps messages for its addresses, and those without an address on its interface | ### History and import diff --git a/docs/cli.md b/docs/cli.md index 56d8d4d..c693864 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -77,8 +77,8 @@ one. An option Steward doesn't know: `--option 224:string=hello`. |---|---| | `reservation list [--subnet SUBNET]` | reservations, whether each client is online, and when it last answered a ping | | `reservation show RES` | RES is its ID, address or MAC address | -| `reservation add IP MAC [--hostname H] [--client-id HEX] [--option NAME=VALUE]... [--comment C]` | `-` for MAC with `--client-id` | -| `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--option]... [--unset-option]... [--comment]` | | +| `reservation add IP MAC [--hostname H] [--client-id HEX] [--lease-time 7d] [--option NAME=VALUE]... [--comment C]` | `-` for MAC with `--client-id`; with both, either matches | +| `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--lease-time 7d\|default] [--option]... [--unset-option]... [--comment]` | | | `reservation enable\|disable RES` | use a reservation, or keep it but ignore it | | `reservation delete RES` | | @@ -88,7 +88,7 @@ one. An option Steward doesn't know: `--option 224:string=hello`. |---|---| | `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]` | make the address its client's reservation | +| `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 delete IP [-y]` | free the address at once | ### activity diff --git a/docs/subnets.md b/docs/subnets.md index 317f69e..2ef8391 100644 --- a/docs/subnets.md +++ b/docs/subnets.md @@ -58,15 +58,20 @@ 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 ``` -* **The client** is known by its MAC address, or by its client identifier - (option 61) when the reservation has one (`--client-id`; give `-` as the - MAC). Some devices change their identifier but not their MAC address, - and others the other way round. When unsure, use the MAC address. +* **The client** is known by its MAC address, its client identifier + (option 61, `--client-id`; give `-` as the MAC for an identifier alone), + or both. With both, **either one** gets the client its address: a + machine that boots two systems sends different identifiers from one MAC + address, and a device with a randomised MAC may keep its identifier. + The Leases page and `steward lease show` show the identifier a client + sent; `steward lease reserve IP` uses both when it sent one (`--by mac` + or `--by client-id` for just one). * **The address picks the subnet**: the subnet whose network holds it. It may be inside a pool or outside one; either way, nobody else gets it. * **The host name** is sent to the client (option 12). * **Options** set on the reservation are for that client only, and win over the subnet's and the settings'. +* **A lease time** of its own (`--lease-time 7d`) wins over the subnet's. * **A device on two VLANs** can have a reservation in each subnet. Within one subnet, a client has at most one. @@ -108,7 +113,9 @@ a list, it gets every option set for it. ## Lease times The settings have the default (12 hours, `steward settings set ---lease-time 1d`); a subnet can have its own (`--lease-time 1h`). Clients +--lease-time 1d`); a subnet can have its own (`--lease-time 1h`), and a +reservation its own over that (`steward reservation update nas +--lease-time 7d`). Clients renew halfway through. Short times let changes reach clients sooner; long ones keep clients working longer if Steward is down. diff --git a/internal/api/types.go b/internal/api/types.go index b84690b..3a12e65 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -96,8 +96,10 @@ type ReservationInput struct { IP *string `json:"ip,omitempty"` Hostname *string `json:"hostname,omitempty"` Options *[]OptionInput `json:"options,omitempty"` - Disabled *bool `json:"disabled,omitempty"` - Comment *string `json:"comment,omitempty"` + // LeaseTime in seconds; 0 for the subnet's. + LeaseTime *int `json:"lease_time,omitempty"` + Disabled *bool `json:"disabled,omitempty"` + Comment *string `json:"comment,omitempty"` } // Subnet is a subnet with its usage. diff --git a/internal/dhcpd/config.go b/internal/dhcpd/config.go index d910482..4842a80 100644 --- a/internal/dhcpd/config.go +++ b/internal/dhcpd/config.go @@ -125,13 +125,11 @@ type client struct { id string // client identifier (option 61) in hex, or "" } -// matches reports whether a reservation is for the client: by client -// identifier when the reservation has one, else by MAC address. +// matches reports whether the reservation is the client's: by its client +// identifier or its MAC address, either one when the reservation has both +// (a dual-boot machine sends different client IDs from one MAC address). func (cl client) matches(r *model.Reservation) bool { - if r.ClientID != "" { - return r.ClientID == cl.id - } - return r.MAC == cl.mac + return r.ClientID != "" && r.ClientID == cl.id || r.MAC != "" && r.MAC == cl.mac } func (cl client) String() string { diff --git a/internal/dhcpd/handle.go b/internal/dhcpd/handle.go index a88c629..3233dcc 100644 --- a/internal/dhcpd/handle.go +++ b/internal/dhcpd/handle.go @@ -190,8 +190,9 @@ func (s *Server) usable(cfg *config, own map[netip.Addr]bool, sub *subnet, ip ne if !sub.Prefix.Contains(ip) || ip == sub.network || ip == sub.broadcast || own[ip] { return false } - if r := cfg.resByIP[ip]; r != nil { - if !cl.matches(r) { + res := cfg.resByIP[ip] + if res != nil { + if !cl.matches(res) { return false } } else { @@ -211,7 +212,10 @@ func (s *Server) usable(cfg *config, own map[netip.Addr]bool, sub *subnet, ip ne return false } } - if l := s.leases[ip]; l != nil && l.HeldAt(now) && (l.State == model.LeaseDeclined || !l.Owns(cl.mac, cl.id)) { + // A lease of the reserved address by the reservation's client under + // its other identity (the other OS of a dual-boot machine) is its own. + if l := s.leases[ip]; l != nil && l.HeldAt(now) && (l.State == model.LeaseDeclined || + !l.Owns(cl.mac, cl.id) && !(res != nil && (client{mac: l.MAC, id: l.ClientID}).matches(res))) { return false } if o, ok := s.offers[ip]; ok && now.Before(o.expires) && o.cl != cl { @@ -296,8 +300,12 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa host = l.Hostname } } + res := cfg.reservationFor(sub.ID, cl) + if res != nil && res.IP != ip { + res = nil + } l := &model.Lease{IP: ip, SubnetID: sub.ID, MAC: cl.mac, ClientID: cl.id, Hostname: host, State: model.LeaseActive, - Start: start, End: now.Add(sub.leaseTime), Interface: iface, LastSeen: seen} + Start: start, End: now.Add(leaseTime(sub, res)), Interface: iface, LastSeen: seen} err := s.putLease(ctx, l) delete(s.offers, ip) s.mu.Unlock() @@ -312,10 +320,6 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa s.warnOnce("sid "+iface, 10*time.Minute, "can't answer DHCP: "+err.Error(), "interface", iface) return nil } - res := cfg.reservationFor(sub.ID, cl) - if res != nil && res.IP != ip { - res = nil - } r := p.Reply(dhcp4.Ack) r.YIAddr = ip r.CIAddr = p.CIAddr @@ -413,6 +417,14 @@ func (s *Server) inform(cfg *config, iface string, p *dhcp4.Packet, cl client) * return &Outbound{Packet: r, Dst: p.CIAddr} } +// leaseTime is a lease's length: the reservation's, else the subnet's. +func leaseTime(sub *subnet, res *model.Reservation) time.Duration { + if res != nil && res.LeaseTime > 0 { + return time.Duration(res.LeaseTime) * time.Second + } + return sub.leaseTime +} + // ---- options // Options always sent when configured, even when the client doesn't ask. @@ -425,7 +437,7 @@ var essential = []byte{dhcp4.OptSubnetMask, dhcp4.OptRouters, dhcp4.OptDNSServer func (s *Server) addOptions(r, req *dhcp4.Packet, sub *subnet, res *model.Reservation, sid netip.Addr, lease bool) { r.Set(dhcp4.OptServerID, dhcp4.IP(sid)) if lease { - lt := uint32(sub.leaseTime / time.Second) + lt := uint32(leaseTime(sub, res) / time.Second) r.Set(dhcp4.OptLeaseTime, dhcp4.U32(lt)) r.Set(dhcp4.OptRenewalTime, dhcp4.U32(lt/2)) r.Set(dhcp4.OptRebindingTime, dhcp4.U32(lt*7/8)) diff --git a/internal/dhcpd/handle_test.go b/internal/dhcpd/handle_test.go index f206cb6..f0fb9a0 100644 --- a/internal/dhcpd/handle_test.go +++ b/internal/dhcpd/handle_test.go @@ -2,6 +2,7 @@ package dhcpd import ( "context" + "encoding/binary" "errors" "net" "net/netip" @@ -115,9 +116,9 @@ func (h *harness) discover(iface string, m net.HardwareAddr, opts ...dhcp4.Optio } // lease runs DISCOVER and REQUEST and returns the ACK. -func (h *harness) lease(iface string, m net.HardwareAddr) *dhcp4.Packet { +func (h *harness) lease(iface string, m net.HardwareAddr, opts ...dhcp4.Option) *dhcp4.Packet { h.t.Helper() - off := h.discover(iface, m) + off := h.discover(iface, m, opts...) if off == nil || off.Packet.Type() != dhcp4.Offer { h.t.Fatalf("no offer for %s on %s", m, iface) } @@ -126,6 +127,9 @@ func (h *harness) lease(iface string, m net.HardwareAddr) *dhcp4.Packet { req.Set(dhcp4.OptServerID, dhcp4.IP(sid)) req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr)) req.Set(dhcp4.OptHostName, []byte("client"+m.String()[15:])) + for _, o := range opts { + req.Set(o.Code, o.Data) + } ack := h.send(iface, req) if ack == nil || ack.Packet.Type() != dhcp4.Ack { h.t.Fatalf("no ACK for %s: %+v", m, ack) @@ -580,3 +584,31 @@ func TestAddressOrder(t *testing.T) { t.Errorf("a full subnet offered %s", out.Packet.YIAddr) } } + +func TestReservationLeaseTimeAndClientID(t *testing.T) { + h := newHarness(t, func(st *model.State) { + st.Reservations[0].LeaseTime = 600 + st.Reservations[0].ClientID = "ff0102" + }) + u32 := func(p *dhcp4.Packet) uint32 { return binary.BigEndian.Uint32(p.Get(dhcp4.OptLeaseTime)) } + id := func(hex ...byte) dhcp4.Option { return dhcp4.Option{Code: dhcp4.OptClientID, Data: hex} } + // By its client ID, from any MAC address, with its own lease time. + ack := h.lease("eth0", mac(0x50), id(0xff, 1, 2)) + if ack.YIAddr.String() != "10.0.10.20" || u32(ack) != 600 { + t.Errorf("by client id: %s %d", ack.YIAddr, u32(ack)) + } + if l := h.s.Lease(ack.YIAddr); !l.End.Equal(h.clock.Add(10 * time.Minute)) { + t.Errorf("lease end %v", l.End) + } + // By its MAC address with another client ID (the machine's other OS): + // the same address, though the first identity's lease is still on. + ack = h.lease("eth0", mac(0x99), id(1, 0xaa, 0, 0, 0, 0, 0x99)) + if ack.YIAddr.String() != "10.0.10.20" { + t.Errorf("by MAC: %s", ack.YIAddr) + } + // Anyone else gets a pool address, with the subnet's lease time. + ack = h.lease("eth0", mac(0x51), id(0xff, 9, 9)) + if ack.YIAddr.String() == "10.0.10.20" || u32(ack) != 43200 { + t.Errorf("stranger: %s %d", ack.YIAddr, u32(ack)) + } +} diff --git a/internal/model/config.go b/internal/model/config.go index 030e18f..5430fb7 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -261,13 +261,17 @@ func Mask(p netip.Prefix) netip.Addr { // MAC address, or by its client identifier (option 61) when ClientID is // set. The subnet is the one whose prefix holds the address. type Reservation struct { - ID int64 `json:"id"` + ID int64 `json:"id"` + // The client is known by its MAC address, its client identifier + // (option 61), or either when both are set. MAC string `json:"mac,omitempty"` ClientID string `json:"client_id,omitempty"` IP netip.Addr `json:"ip"` // Hostname is sent to the client (option 12). Hostname string `json:"hostname"` Options []OptionValue `json:"options"` + // LeaseTime overrides the subnet's lease time (0: inherit). + LeaseTime int `json:"lease_time,omitempty"` // Disabled reservations are kept but ignored: the client is treated // like any other, and an address inside a pool may go to others. Disabled bool `json:"disabled"` diff --git a/internal/server/h_config.go b/internal/server/h_config.go index 7a8e69a..fa7ac10 100644 --- a/internal/server/h_config.go +++ b/internal/server/h_config.go @@ -440,6 +440,9 @@ func applyReservationInput(res *model.Reservation, in *api.ReservationInput) err } res.Options = opts } + if in.LeaseTime != nil { + res.LeaseTime = *in.LeaseTime + } if in.Disabled != nil { res.Disabled = *in.Disabled } diff --git a/internal/server/h_leases.go b/internal/server/h_leases.go index 825af71..f41f342 100644 --- a/internal/server/h_leases.go +++ b/internal/server/h_leases.go @@ -122,14 +122,38 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr var in struct { Hostname *string `json:"hostname,omitempty"` Comment string `json:"comment,omitempty"` + // 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"` } if err := decodeOptional(r, &in); err != nil { return err } l := s.dhcp.Lease(ip) - if l == nil || l.MAC == "" { + if l == nil || l.MAC == "" && l.ClientID == "" { return notFound("no lease for %s", ip) } + mac, cid := l.MAC, l.ClientID + switch strings.ReplaceAll(in.By, "-", "_") { + case "", "both": + case "mac": + cid = "" + case "client_id": + mac = "" + default: + return badRequest("by %q: use mac, client_id or both", in.By) + } + if mac == "" && cid == "" { + what := "client ID (option 61); reserve it by mac" + if in.By == "mac" { + what = "MAC address; reserve it by client_id" + } + return badRequest("the client of %s sent no %s", ip, what) + } + who := mac + if cid != "" { + who = strings.TrimSpace(mac + " client id " + cid) + } host := l.Hostname if in.Hostname != nil { host = *in.Hostname @@ -137,9 +161,9 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr host = "" } var id int64 - return s.change(w, r, p, "reserve "+ip.String()+" for "+l.MAC, func(st *model.State, newID func() int64) error { + return s.change(w, r, p, "reserve "+ip.String()+" for "+who, func(st *model.State, newID func() int64) error { id = newID() - st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: l.MAC, IP: ip, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment}) + st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: mac, ClientID: cid, IP: ip, 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 b05844b..31f9000 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -434,3 +434,45 @@ func TestAddressOrderAPI(t *testing.T) { t.Errorf("bad pool order: %d", code) } } + +func TestReserveByClientID(t *testing.T) { + e := newEnv(t) + e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), Pools: &[]string{"10.0.20.100-10.0.20.110"}}, nil) + get := func(id byte) netip.Addr { + m := net.HardwareAddr{0xaa, 0, 0, 0, 0, id} + disc := dhcp4.NewRequest(dhcp4.Discover, m, 1) + disc.Set(dhcp4.OptClientID, []byte{0xff, id}) + off := e.dhcp.Handle(context.Background(), "eth0.20", disc) + req := dhcp4.NewRequest(dhcp4.Request, m, 2) + req.Set(dhcp4.OptClientID, []byte{0xff, id}) + req.Set(dhcp4.OptServerID, dhcp4.IP(netip.MustParseAddr("10.0.20.1"))) + req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr)) + e.dhcp.Handle(context.Background(), "eth0.20", req) + return off.Packet.YIAddr + } + a, b := get(1), get(2) + var mr api.MutationResponse + if code := e.call(e.token, "POST", "/leases/"+a.String()+"/reserve", map[string]any{}, &mr); code != 201 { + t.Fatalf("reserve: %d", code) + } + var rv api.Reservation + json.Unmarshal(mr.Object, &rv) + 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 { + t.Fatalf("reserve by id: %d", code) + } + rv = api.Reservation{} + json.Unmarshal(mr.Object, &rv) + if rv.MAC != "" || rv.ClientID != "ff02" { + t.Errorf("by client id: %+v", rv) + } + var eb api.ErrorBody + if code := e.call(e.token, "PATCH", "/reservations/"+itoa(rv.ID), api.ReservationInput{LeaseTime: ptr(10)}, &eb); code != 422 || eb.Error.Problems[0].Code != "lease_time_range" { + t.Errorf("short lease time: %d %+v", code, eb) + } + if code := e.call(e.token, "PATCH", "/reservations/"+itoa(rv.ID), api.ReservationInput{LeaseTime: ptr(7 * 86400)}, nil); code != 200 || e.eng.State().ReservationByID(rv.ID).LeaseTime != 7*86400 { + t.Errorf("lease time: %d", code) + } +} diff --git a/internal/store/state.go b/internal/store/state.go index 4c16648..ae89fc1 100644 --- a/internal/store/state.go +++ b/internal/store/state.go @@ -50,7 +50,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) { if err := rows.Err(); err != nil { return nil, err } - rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, disabled, comment FROM reservations ORDER BY id`) + rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, lease_time, disabled, comment FROM reservations ORDER BY id`) if err != nil { return nil, err } @@ -59,7 +59,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) { r := &model.Reservation{} var ip, opts string var disabled int - if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &disabled, &r.Comment); err != nil { + if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &r.LeaseTime, &disabled, &r.Comment); err != nil { return nil, err } r.IP, _ = netip.ParseAddr(ip) @@ -160,10 +160,10 @@ func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error { if err := json.Unmarshal(c.After, &r); err != nil { return err } - _, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?) + _, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, lease_time, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET mac = excluded.mac, client_id = excluded.client_id, ip = excluded.ip, hostname = excluded.hostname, - options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`, - r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), boolInt(r.Disabled), r.Comment) + options = excluded.options, lease_time = excluded.lease_time, disabled = excluded.disabled, comment = excluded.comment`, + r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), r.LeaseTime, boolInt(r.Disabled), r.Comment) return err } return fmt.Errorf("unknown kind %q", c.Kind) diff --git a/internal/store/store.go b/internal/store/store.go index fec26d5..b01c87d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -206,6 +206,10 @@ ALTER TABLE leases ADD COLUMN last_seen TEXT; ` ALTER TABLE subnets ADD COLUMN pool_order TEXT NOT NULL DEFAULT ''; ALTER TABLE subnets ADD COLUMN address_order TEXT NOT NULL DEFAULT ''; +`, + // 6: a reservation's own lease time. + ` +ALTER TABLE reservations ADD COLUMN lease_time INTEGER NOT NULL DEFAULT 0; `, } diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 1a3c10a..8b9be57 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -294,14 +294,21 @@ func (c *checker) reservations(st *model.State, env *Env) { c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label()) } byIP[r.IP] = r - k := key{s.ID, "mac " + r.MAC} - if r.ClientID != "" { - k.id = "id " + r.ClientID + // Either of a client's MAC address and client ID finds its + // reservation, so neither may be another's. + for _, k := range []key{{s.ID, "mac " + r.MAC}, {s.ID, "id " + r.ClientID}} { + if k.id == "mac " || k.id == "id " { + continue + } + if o := byClient[k]; o != nil && o != r { + c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label()) + break + } + byClient[k] = r } - if o := byClient[k]; o != nil { - c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label()) - } - byClient[k] = r + } + if r.LeaseTime != 0 { + c.leaseTime(r.LeaseTime, obj) } if r.Hostname != "" && !validHostname(r.Hostname) { c.add(Error, "reservation_hostname", obj, "%q isn't a valid host name (letters, digits and '-', labels up to 63 characters, separated by dots)", r.Hostname) diff --git a/internal/web/static/assets/pages/leases.js b/internal/web/static/assets/pages/leases.js index d8936b5..341b99a 100644 --- a/internal/web/static/assets/pages/leases.js +++ b/internal/web/static/assets/pages/leases.js @@ -16,7 +16,8 @@ export function leasesTable(leases, done, opts = {}) { const write = can("leases", true), config = can("config", true); return table([ { label: "Address", render: (l) => h("span", { class: "mono" }, l.ip) }, - { label: "MAC address", render: (l) => h("span", { class: "mono" }, l.mac || "–") }, + { label: "Client", render: (l) => h("span", { class: "mono" }, l.mac || "–", + l.client_id ? h("div", { class: "muted small", title: "Client identifier (option 61)" }, "id " + (l.client_id.length > 20 ? l.client_id.slice(0, 18) + "…" : l.client_id)) : null) }, { label: "Host name", render: leaseHost }, { label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] }, { label: "Ends", render: (l) => time(l.end) }, @@ -24,7 +25,7 @@ export function leasesTable(leases, done, opts = {}) { opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" }, { label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) }, { label: "", tdClass: "actions-cell", render: (l) => [ - config && !l.reservation && l.mac && l.state !== "declined" ? h("button", { class: "small reveal", title: "Give this client this address from now on", onclick: () => reserve(l, done) }, "Reserve") : null, " ", + config && !l.reservation && (l.mac || l.client_id) && l.state !== "declined" ? h("button", { class: "small reveal", title: "Give this client this address from now on", onclick: () => reserve(l, done) }, "Reserve") : null, " ", write ? h("button", { class: "small reveal", title: "Forget this lease: the address is free at once", onclick: () => del(l, done) }, "Delete") : null, ] }, ].filter(Boolean), leases, { empty: opts.empty || "No leases." }); @@ -36,14 +37,17 @@ function stateBadge(st) { async function reserve(l, done) { 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 + " keeps " + l.ip + " from now on."), - h("div", { class: "form-grid" }, h("label", null, "Host name"), host), err), [ + h("p", null, (l.mac || "The client") + " keeps " + l.ip + " from now on."), + h("div", { class: "form-grid" }, 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() }); + await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim(), by: l.client_id && l.mac ? by.value : undefined }); done(); return true; } catch (e) { diff --git a/internal/web/static/assets/pages/reservations.js b/internal/web/static/assets/pages/reservations.js index c030699..2f28333 100644 --- a/internal/web/static/assets/pages/reservations.js +++ b/internal/web/static/assets/pages/reservations.js @@ -1,7 +1,7 @@ // Reservations: fixed addresses for clients. import { h, mount, table, time, input, modal, errorBox, field, confirmDialog, select, debounce, badge, checkbox } from "../ui.js"; import { get, change, can } from "../api.js"; -import { optionsEditor, subnetLabel } from "../widgets.js"; +import { optionsEditor, subnetLabel, fmtSeconds, parseSeconds } from "../widgets.js"; import { onLeave } from "../app.js"; // reservationsTable lists reservations with Edit and Delete. @@ -9,7 +9,7 @@ export function reservationsTable(rs, done, opts = {}) { const write = can("config", true); return table([ { label: "Address", render: (r) => [h("span", { class: "mono" }, r.ip), r.disabled ? [" ", badge("disabled", "off")] : null] }, - { label: "Client", render: (r) => h("span", { class: "mono" }, r.client_id ? "id " + r.client_id : r.mac) }, + { label: "Client", render: (r) => h("span", { class: "mono" }, r.mac || "", r.mac && r.client_id ? h("span", { class: "muted small" }, " or ") : "", r.client_id ? "id " + r.client_id : "") }, { label: "Host name", render: (r) => r.hostname || h("span", { class: "muted" }, "–") }, opts.subnet ? null : { label: "Subnet", render: (r) => r.subnet_id ? h("a", { href: "#/subnets/" + r.subnet_id }, r.subnet) : "–" }, { label: "Online", render: (r) => r.lease @@ -51,7 +51,8 @@ export async function reservationDialog(r, done, preset = {}) { const inherited = [...settings.options.filter((o) => !(sub && sub.options.some((x) => x.code === o.code))), ...(sub ? sub.options : [])]; const ip = input({ value: r ? r.ip : preset.ip || "", placeholder: "10.0.10.20", class: "mono" }); const mac = input({ value: r ? r.mac || "" : preset.mac || "", placeholder: "aa:bb:cc:dd:ee:ff", class: "mono" }); - const cid = input({ value: r ? r.client_id || "" : "", placeholder: "only for clients known by a client identifier", class: "mono" }); + const cid = input({ value: r ? r.client_id || "" : preset.client_id || "", placeholder: "optional: option 61, in hex", class: "mono" }); + const lease = input({ value: r && r.lease_time ? fmtSeconds(r.lease_time) : "", placeholder: "the subnet's" }); const host = input({ value: r ? r.hostname : preset.hostname || "", placeholder: "nas" }); const opts = await optionsEditor(r ? r.options : [], inherited); const comment = input({ value: r ? r.comment : "" }); @@ -60,7 +61,8 @@ export async function reservationDialog(r, done, preset = {}) { const body = h("div", null, h("div", { class: "form-grid" }, field("Address", ip, "Its subnet is the one whose network holds it. Inside a pool is fine: nobody else gets it."), field("MAC address", mac), - field("Client identifier", cid, "Optional: match the client's identifier (option 61, hex) instead of its MAC address."), + field("Client identifier", cid, "Optional: the client's identifier (option 61, hex). With a MAC address too, either one gets the client this address (a machine that boots two systems sends different identifiers)."), + field("Lease time", lease, "This client's lease time (12h, 7d); empty for the subnet's."), field("Host name", host, "Sent to the client."), field("Options", opts, "Only for this client; they win over the subnet's."), field("Comment", comment), @@ -69,7 +71,9 @@ export async function reservationDialog(r, done, preset = {}) { { label: "Cancel", value: null }, { label: r ? "Save" : "Add reservation", kind: "primary", onclick: async () => { mount(err); - const v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), comment: comment.value }; + const lt = parseSeconds(lease.value); + if (lt === null) { mount(err, h("div", { class: "msg err" }, "Lease time: write it as 12h, 7d or 30m")); return false; } + const v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), lease_time: lt, comment: comment.value }; if (r) v.disabled = disabled.input.checked; try { await change(r ? "PATCH" : "POST", r ? "/reservations/" + r.id : "/reservations", v); diff --git a/scripts/e2e.sh b/scripts/e2e.sh index ae95cdc..408d42f 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -234,6 +234,12 @@ 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 +# ---- a reservation's own lease time + +run "reservation lease time" 0 S reservation update $NA.20 --lease-time 2h +run "the reserved client gets it" 0 udhcpc a 02:00:00:00:00:20 +contains "BOUND ip=$NA.20 .*lease=7200" + # ---- backup run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup