Compare commits
2
Commits
| 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
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
||||||
[Semantic Versioning](https://semver.org/).
|
[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
|
The first version of Steward, a DHCPv4 server with a web UI, a REST API
|
||||||
and a CLI, built the way Jarl is.
|
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`),
|
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.
|
||||||
|
|||||||
@@ -107,10 +107,11 @@ sudo systemctl disable --now kea-dhcp4-server
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Steward 0.1.0 is in testing (0.1.0-rc.1): a DHCPv4 server with subnets per
|
Version 0.1.0 is the current release: a DHCPv4 server with subnets per
|
||||||
interface, reservations, a web UI, REST API and CLI, Kea import, the web
|
interface, reservations (by MAC address, client ID or either, with their
|
||||||
UI's certificate through Jarl, clients' last-seen times and a choice of
|
own lease time), a web UI, REST API and CLI, Kea import, the web UI's
|
||||||
address order per subnet and pool.
|
certificate through Jarl, clients' last-seen times and a choice of address
|
||||||
|
order per subnet and pool.
|
||||||
|
|
||||||
## Licence
|
## 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")
|
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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ 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"`
|
||||||
|
// LeaseTime in seconds; 0 for the subnet's.
|
||||||
|
LeaseTime *int `json:"lease_time,omitempty"`
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -262,12 +262,16 @@ func Mask(p netip.Prefix) netip.Addr {
|
|||||||
// 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"`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -294,15 +294,22 @@ 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 {
|
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())
|
c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label())
|
||||||
|
break
|
||||||
}
|
}
|
||||||
byClient[k] = r
|
byClient[k] = r
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if r.LeaseTime != 0 {
|
||||||
|
c.leaseTime(r.LeaseTime, obj)
|
||||||
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user