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 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-18 20:26:24 +01:00
1 parent abb2ee0fd7
commit 4fd72998ec
20 files changed
+252 -65

No files matched your search

+6
View File
@@ -124,6 +124,12 @@ and a CLI, built the way Jarl is.
used in the order listed, or one at random (`--pool-order random`), 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 weighted by its never-used addresses. Pools now keep the order they are
given in. Kea's `allocator: random` is imported. 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`, - **Token rotation** (`steward token rotate`, `POST /tokens/{id}/rotate`,
Rotate in the web UI): a new secret for a token, with an optional grace Rotate in the web UI): a new secret for a token, with an optional grace
period for the old one, as in Jarl. period for the old one, as in Jarl.
+10 -2
View File
@@ -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") del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del) cmd.AddCommand(del)
var hostname, comment string var hostname, comment, by 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",
Long: `Makes the address a client has now its reservation: it keeps it from 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 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), 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()
@@ -146,6 +150,9 @@ then on. The host name the client sent becomes the reservation's, unless
if comment != "" { if comment != "" {
body["comment"] = comment body["comment"] = comment
} }
if by != "" {
body["by"] = by
}
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
@@ -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(&hostname, "hostname", "", "the reservation's host name")
reserve.Flags().StringVar(&comment, "comment", "", "a comment") 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) cmd.AddCommand(reserve)
return cmd return cmd
} }
+32 -8
View File
@@ -385,7 +385,7 @@ func reservationCmd() *cobra.Command {
for _, r := range rs { for _, r := range rs {
who := r.MAC who := r.MAC
if r.ClientID != "" { if r.ClientID != "" {
who = "id " + r.ClientID who = strings.TrimPrefix(r.MAC+" or id "+r.ClientID, " or ")
} }
lease := "-" lease := "-"
if r.Lease != nil { if r.Lease != nil {
@@ -427,6 +427,11 @@ func reservationCmd() *cobra.Command {
} }
fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname)) fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname))
fmt.Fprintln(w, "Options:\t"+optionsText(r.Options)) 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 { 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) 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 { 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 var opts, unsetOpts []string
add := &cobra.Command{ add := &cobra.Command{
Use: "add IP MAC", Use: "add IP MAC",
Short: "Reserve an address for a client", Short: "Reserve an address for a client",
Long: `Reserves an address for a client, known by its MAC address (or by its Long: `Reserves an address for a client, known by its MAC address, its client
client identifier: give "-" for the MAC and --client-id). The address picks identifier (option 61: give "-" for the MAC and --client-id), or both:
the subnet. An address inside a pool is fine: it is never handed out to then either one gets it the address, as for a machine that boots two
anyone else. 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), Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient() c, err := newClient()
@@ -497,6 +503,13 @@ anyone else.
if comment != "" { if comment != "" {
in.Comment = &comment in.Comment = &comment
} }
if leaseTime != "" {
n, err := parseSeconds(leaseTime)
if err != nil {
return err
}
in.LeaseTime = &n
}
if len(opts) > 0 { if len(opts) > 0 {
o, err := optionsArg(opts) o, err := optionsArg(opts)
if err != nil { if err != nil {
@@ -513,7 +526,8 @@ anyone else.
} }
f := add.Flags() f := add.Flags()
f.StringVar(&hostname, "hostname", "", "the host name to send it") 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.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE for this client only")
f.StringVar(&comment, "comment", "", "a comment") f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add) cmd.AddCommand(add)
@@ -547,6 +561,15 @@ anyone else.
if fl.Changed("comment") { if fl.Changed("comment") {
in.Comment = &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 { if len(opts) > 0 || len(unsetOpts) > 0 {
set, err := optionsArg(opts) set, err := optionsArg(opts)
if err != nil { if err != nil {
@@ -570,6 +593,7 @@ anyone else.
f.StringVar(&mac, "mac", "", "a new MAC address (\"\" for none)") f.StringVar(&mac, "mac", "", "a new MAC address (\"\" for none)")
f.StringVar(&clientID, "client-id", "", "a client identifier (\"\" for none)") f.StringVar(&clientID, "client-id", "", "a client identifier (\"\" for none)")
f.StringVar(&hostname, "hostname", "", "a new host name") 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(&opts, "option", nil, "set an option NAME=VALUE")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option") f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
f.StringVar(&comment, "comment", "", "a comment") f.StringVar(&comment, "comment", "", "a comment")
+2 -2
View File
@@ -111,7 +111,7 @@ out. `lease_time` 0 means the settings'.
| | | | | |
|---|---| |---|---|
| `GET /reservations[?subnet=REF]` | each with `subnet_id`, `subnet` and its current `lease` | | `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) | | `GET`, `PATCH`, `DELETE /reservations/{ref}` | by ID, address, or MAC address (when it has one reservation) |
### Leases and activity ### 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[?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"}` | | `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 | | `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 ### History and import
+3 -3
View File
@@ -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 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 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 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] [--option]... [--unset-option]... [--comment]` | | | `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 enable\|disable RES` | use a reservation, or keep it but ignore it |
| `reservation delete RES` | | | `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 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]` | 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 | | `lease delete IP [-y]` | free the address at once |
### activity ### activity
+12 -5
View File
@@ -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 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 * **The client** is known by its MAC address, its client identifier
(option 61) when the reservation has one (`--client-id`; give `-` as the (option 61, `--client-id`; give `-` as the MAC for an identifier alone),
MAC). Some devices change their identifier but not their MAC address, or both. With both, **either one** gets the client its address: a
and others the other way round. When unsure, use the MAC address. 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 * **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. may be inside a pool or outside one; either way, nobody else gets it.
* **The host name** is sent to the client (option 12). * **The host name** is sent to the client (option 12).
* **Options** set on the reservation are for that client only, and win * **Options** set on the reservation are for that client only, and win
over the subnet's and the settings'. 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 * **A device on two VLANs** can have a reservation in each subnet. Within
one subnet, a client has at most one. one subnet, a client has at most one.
@@ -108,7 +113,9 @@ a list, it gets every option set for it.
## Lease times ## Lease times
The settings have the default (12 hours, `steward settings set 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; renew halfway through. Short times let changes reach clients sooner;
long ones keep clients working longer if Steward is down. long ones keep clients working longer if Steward is down.
+4 -2
View File
@@ -96,8 +96,10 @@ type ReservationInput struct {
IP *string `json:"ip,omitempty"` IP *string `json:"ip,omitempty"`
Hostname *string `json:"hostname,omitempty"` Hostname *string `json:"hostname,omitempty"`
Options *[]OptionInput `json:"options,omitempty"` Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"` // LeaseTime in seconds; 0 for the subnet's.
Comment *string `json:"comment,omitempty"` LeaseTime *int `json:"lease_time,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
} }
// Subnet is a subnet with its usage. // Subnet is a subnet with its usage.
+4 -6
View File
@@ -125,13 +125,11 @@ type client struct {
id string // client identifier (option 61) in hex, or "" id string // client identifier (option 61) in hex, or ""
} }
// matches reports whether a reservation is for the client: by client // matches reports whether the reservation is the client's: by its client
// identifier when the reservation has one, else by MAC address. // 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 { func (cl client) matches(r *model.Reservation) bool {
if r.ClientID != "" { return r.ClientID != "" && r.ClientID == cl.id || r.MAC != "" && r.MAC == cl.mac
return r.ClientID == cl.id
}
return r.MAC == cl.mac
} }
func (cl client) String() string { func (cl client) String() string {
+21 -9
View File
@@ -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] { if !sub.Prefix.Contains(ip) || ip == sub.network || ip == sub.broadcast || own[ip] {
return false return false
} }
if r := cfg.resByIP[ip]; r != nil { res := cfg.resByIP[ip]
if !cl.matches(r) { if res != nil {
if !cl.matches(res) {
return false return false
} }
} else { } else {
@@ -211,7 +212,10 @@ func (s *Server) usable(cfg *config, own map[netip.Addr]bool, sub *subnet, ip ne
return false 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 return false
} }
if o, ok := s.offers[ip]; ok && now.Before(o.expires) && o.cl != cl { 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 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, 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) err := s.putLease(ctx, l)
delete(s.offers, ip) delete(s.offers, ip)
s.mu.Unlock() 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) s.warnOnce("sid "+iface, 10*time.Minute, "can't answer DHCP: "+err.Error(), "interface", iface)
return nil return nil
} }
res := cfg.reservationFor(sub.ID, cl)
if res != nil && res.IP != ip {
res = nil
}
r := p.Reply(dhcp4.Ack) r := p.Reply(dhcp4.Ack)
r.YIAddr = ip r.YIAddr = ip
r.CIAddr = p.CIAddr 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} 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
// Options always sent when configured, even when the client doesn't ask. // 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) { 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)) r.Set(dhcp4.OptServerID, dhcp4.IP(sid))
if lease { 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.OptLeaseTime, dhcp4.U32(lt))
r.Set(dhcp4.OptRenewalTime, dhcp4.U32(lt/2)) r.Set(dhcp4.OptRenewalTime, dhcp4.U32(lt/2))
r.Set(dhcp4.OptRebindingTime, dhcp4.U32(lt*7/8)) r.Set(dhcp4.OptRebindingTime, dhcp4.U32(lt*7/8))
+34 -2
View File
@@ -2,6 +2,7 @@ package dhcpd
import ( import (
"context" "context"
"encoding/binary"
"errors" "errors"
"net" "net"
"net/netip" "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. // 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() h.t.Helper()
off := h.discover(iface, m) off := h.discover(iface, m, opts...)
if off == nil || off.Packet.Type() != dhcp4.Offer { if off == nil || off.Packet.Type() != dhcp4.Offer {
h.t.Fatalf("no offer for %s on %s", m, iface) 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.OptServerID, dhcp4.IP(sid))
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr)) req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr))
req.Set(dhcp4.OptHostName, []byte("client"+m.String()[15:])) req.Set(dhcp4.OptHostName, []byte("client"+m.String()[15:]))
for _, o := range opts {
req.Set(o.Code, o.Data)
}
ack := h.send(iface, req) ack := h.send(iface, req)
if ack == nil || ack.Packet.Type() != dhcp4.Ack { if ack == nil || ack.Packet.Type() != dhcp4.Ack {
h.t.Fatalf("no ACK for %s: %+v", m, 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) 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))
}
}
+5 -1
View File
@@ -261,13 +261,17 @@ func Mask(p netip.Prefix) netip.Addr {
// MAC address, or by its client identifier (option 61) when ClientID is // MAC address, or by its client identifier (option 61) when ClientID is
// set. The subnet is the one whose prefix holds the address. // set. The subnet is the one whose prefix holds the address.
type Reservation struct { 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"` MAC string `json:"mac,omitempty"`
ClientID string `json:"client_id,omitempty"` ClientID string `json:"client_id,omitempty"`
IP netip.Addr `json:"ip"` IP netip.Addr `json:"ip"`
// Hostname is sent to the client (option 12). // Hostname is sent to the client (option 12).
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
Options []OptionValue `json:"options"` 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 // Disabled reservations are kept but ignored: the client is treated
// like any other, and an address inside a pool may go to others. // like any other, and an address inside a pool may go to others.
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
+3
View File
@@ -440,6 +440,9 @@ func applyReservationInput(res *model.Reservation, in *api.ReservationInput) err
} }
res.Options = opts res.Options = opts
} }
if in.LeaseTime != nil {
res.LeaseTime = *in.LeaseTime
}
if in.Disabled != nil { if in.Disabled != nil {
res.Disabled = *in.Disabled res.Disabled = *in.Disabled
} }
+27 -3
View File
@@ -122,14 +122,38 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr
var in struct { var in struct {
Hostname *string `json:"hostname,omitempty"` Hostname *string `json:"hostname,omitempty"`
Comment string `json:"comment,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 { if err := decodeOptional(r, &in); err != nil {
return err return err
} }
l := s.dhcp.Lease(ip) l := s.dhcp.Lease(ip)
if l == nil || l.MAC == "" { if l == nil || l.MAC == "" && l.ClientID == "" {
return notFound("no lease for %s", ip) 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 host := l.Hostname
if in.Hostname != nil { if in.Hostname != nil {
host = *in.Hostname host = *in.Hostname
@@ -137,9 +161,9 @@ func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Pr
host = "" host = ""
} }
var id int64 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() 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 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 {
+42
View File
@@ -434,3 +434,45 @@ func TestAddressOrderAPI(t *testing.T) {
t.Errorf("bad pool order: %d", code) 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)
}
}
+5 -5
View File
@@ -50,7 +50,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -59,7 +59,7 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
r := &model.Reservation{} r := &model.Reservation{}
var ip, opts string var ip, opts string
var disabled int 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 return nil, err
} }
r.IP, _ = netip.ParseAddr(ip) 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 { if err := json.Unmarshal(c.After, &r); err != nil {
return err 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, 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`, 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)), boolInt(r.Disabled), r.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 err
} }
return fmt.Errorf("unknown kind %q", c.Kind) return fmt.Errorf("unknown kind %q", c.Kind)
+4
View File
@@ -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 pool_order TEXT NOT NULL DEFAULT '';
ALTER TABLE subnets ADD COLUMN address_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;
`, `,
} }
+14 -7
View File
@@ -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()) c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label())
} }
byIP[r.IP] = r byIP[r.IP] = r
k := key{s.ID, "mac " + r.MAC} // Either of a client's MAC address and client ID finds its
if r.ClientID != "" { // reservation, so neither may be another's.
k.id = "id " + r.ClientID 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()) if r.LeaseTime != 0 {
} c.leaseTime(r.LeaseTime, obj)
byClient[k] = r
} }
if r.Hostname != "" && !validHostname(r.Hostname) { 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) 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)
+9 -5
View File
@@ -16,7 +16,8 @@ export function leasesTable(leases, done, opts = {}) {
const write = can("leases", true), config = can("config", true); const write = can("leases", true), config = can("config", true);
return table([ return table([
{ label: "Address", render: (l) => h("span", { class: "mono" }, l.ip) }, { 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: "Host name", render: leaseHost },
{ label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] }, { label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] },
{ label: "Ends", render: (l) => time(l.end) }, { 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 || "–" }, opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" },
{ label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) }, { label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) },
{ label: "", tdClass: "actions-cell", render: (l) => [ { 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, 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." }); ].filter(Boolean), leases, { empty: opts.empty || "No leases." });
@@ -36,14 +37,17 @@ function stateBadge(st) {
async function reserve(l, done) { async function reserve(l, done) {
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 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 + " keeps " + l.ip + " from now on."), h("p", null, (l.mac || "The client") + " keeps " + l.ip + " from now on."),
h("div", { class: "form-grid" }, h("label", null, "Host name"), host), err), [ 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: "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() }); await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim(), by: l.client_id && l.mac ? by.value : undefined });
done(); done();
return true; return true;
} catch (e) { } catch (e) {
@@ -1,7 +1,7 @@
// Reservations: fixed addresses for clients. // Reservations: fixed addresses for clients.
import { h, mount, table, time, input, modal, errorBox, field, confirmDialog, select, debounce, badge, checkbox } from "../ui.js"; 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 { 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"; import { onLeave } from "../app.js";
// reservationsTable lists reservations with Edit and Delete. // reservationsTable lists reservations with Edit and Delete.
@@ -9,7 +9,7 @@ export function reservationsTable(rs, done, opts = {}) {
const write = can("config", true); const write = can("config", true);
return table([ return table([
{ label: "Address", render: (r) => [h("span", { class: "mono" }, r.ip), r.disabled ? [" ", badge("disabled", "off")] : null] }, { 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" }, "–") }, { 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) : "–" }, 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 { 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 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 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 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 host = input({ value: r ? r.hostname : preset.hostname || "", placeholder: "nas" });
const opts = await optionsEditor(r ? r.options : [], inherited); const opts = await optionsEditor(r ? r.options : [], inherited);
const comment = input({ value: r ? r.comment : "" }); 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" }, 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("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("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("Host name", host, "Sent to the client."),
field("Options", opts, "Only for this client; they win over the subnet's."), field("Options", opts, "Only for this client; they win over the subnet's."),
field("Comment", comment), field("Comment", comment),
@@ -69,7 +71,9 @@ export async function reservationDialog(r, done, preset = {}) {
{ label: "Cancel", value: null }, { label: "Cancel", value: null },
{ label: r ? "Save" : "Add reservation", kind: "primary", onclick: async () => { { label: r ? "Save" : "Add reservation", kind: "primary", onclick: async () => {
mount(err); 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; if (r) v.disabled = disabled.input.checked;
try { try {
await change(r ? "PATCH" : "POST", r ? "/reservations/" + r.id : "/reservations", v); await change(r ? "PATCH" : "POST", r ? "/reservations/" + r.id : "/reservations", v);
+6
View File
@@ -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] " 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 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 # ---- backup
run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup run "backup" 0 docker exec ${P}-srv /w/stewardd -c /w/steward.yaml backup