Compare commits
2
Commits
v0.1.0-rc.1
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3c8329ae8 | ||
|
|
4fd72998ec |
No files matched your search
+7
-1
@@ -4,7 +4,7 @@ All notable changes to Steward are documented here. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
||||
[Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.1.0] - 2026-10-18
|
||||
|
||||
The first version of Steward, a DHCPv4 server with a web UI, a REST API
|
||||
and a CLI, built the way Jarl is.
|
||||
@@ -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.
|
||||
|
||||
@@ -107,10 +107,11 @@ sudo systemctl disable --now kea-dhcp4-server
|
||||
|
||||
## Status
|
||||
|
||||
Steward 0.1.0 is in testing (0.1.0-rc.1): a DHCPv4 server with subnets per
|
||||
interface, reservations, a web UI, REST API and CLI, Kea import, the web
|
||||
UI's certificate through Jarl, clients' last-seen times and a choice of
|
||||
address order per subnet and pool.
|
||||
Version 0.1.0 is the current release: a DHCPv4 server with subnets per
|
||||
interface, reservations (by MAC address, client ID or either, with their
|
||||
own lease time), a web UI, REST API and CLI, Kea import, the web UI's
|
||||
certificate through Jarl, clients' last-seen times and a choice of address
|
||||
order per subnet and pool.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+10
-2
@@ -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
|
||||
}
|
||||
|
||||
+32
-8
@@ -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")
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+12
-5
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user