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

+3
View File
@@ -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
}
+27 -3
View File
@@ -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 {
+42
View File
@@ -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)
}
}