Disable reservations; subnet enable/disable buttons; page-head CSS fix
release / release (push) Successful in 5m53s
release / release (push) Successful in 5m53s
- A disabled reservation is kept but ignored: the client gets a pool address like any other (steward reservation disable|enable, buttons and a checkbox in the web UI). Schema migration 3. - Subnets get Disable/Enable buttons on their page and in the list. - A rule left without its selector in style.css broke the page heading's layout, so the New ... buttons sat cramped under the description. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7b3f0f764c
commit
6ad1bb9a0d
21 files changed
+165
-37
No files matched your search
@@ -102,6 +102,9 @@ and a CLI, built the way Jarl is.
|
|||||||
- **HTTPS and plain HTTP on one port:** with a certificate, plain HTTP is
|
- **HTTPS and plain HTTP on one port:** with a certificate, plain HTTP is
|
||||||
redirected to HTTPS, except from the server itself (also for
|
redirected to HTTPS, except from the server itself (also for
|
||||||
`tls.cert_file`).
|
`tls.cert_file`).
|
||||||
|
- **Disabled reservations** (`steward reservation disable|enable`, Disable
|
||||||
|
in the web UI): kept but ignored, so the client gets a pool address like
|
||||||
|
any other. Subnets get Disable/Enable buttons in the web UI too.
|
||||||
- **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.
|
||||||
|
|||||||
+31
-2
@@ -330,7 +330,7 @@ func reservationCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(rs, func(w io.Writer) {
|
return output(rs, func(w io.Writer) {
|
||||||
fmt.Fprintln(w, "ID\tIP\tMAC / CLIENT ID\tHOSTNAME\tSUBNET\tLEASE\tCOMMENT")
|
fmt.Fprintln(w, "ID\tIP\tMAC / CLIENT ID\tHOSTNAME\tSUBNET\tLEASE\tSTATE\tCOMMENT")
|
||||||
for _, r := range rs {
|
for _, r := range rs {
|
||||||
who := r.MAC
|
who := r.MAC
|
||||||
if r.ClientID != "" {
|
if r.ClientID != "" {
|
||||||
@@ -340,7 +340,11 @@ func reservationCmd() *cobra.Command {
|
|||||||
if r.Lease != nil {
|
if r.Lease != nil {
|
||||||
lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04")
|
lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04")
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.IP, who, orDash(r.Hostname), orDash(r.SubnetName), lease, r.Comment)
|
state := "on"
|
||||||
|
if r.Disabled {
|
||||||
|
state = "disabled"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.IP, who, orDash(r.Hostname), orDash(r.SubnetName), lease, state, r.Comment)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -374,12 +378,37 @@ func reservationCmd() *cobra.Command {
|
|||||||
} else {
|
} else {
|
||||||
fmt.Fprintln(w, "Lease:\tnone now")
|
fmt.Fprintln(w, "Lease:\tnone now")
|
||||||
}
|
}
|
||||||
|
if r.Disabled {
|
||||||
|
fmt.Fprintln(w, "State:\tdisabled (ignored: the client gets a pool address like any other)")
|
||||||
|
}
|
||||||
if r.Comment != "" {
|
if r.Comment != "" {
|
||||||
fmt.Fprintf(w, "Comment:\t%s\n", r.Comment)
|
fmt.Fprintf(w, "Comment:\t%s\n", r.Comment)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
for _, on := range []bool{true, false} {
|
||||||
|
on := on
|
||||||
|
verb, short := "disable", "Ignore a reservation, keeping it (the client gets a pool address like any other)"
|
||||||
|
if on {
|
||||||
|
verb, short = "enable", "Use a disabled reservation again"
|
||||||
|
}
|
||||||
|
cmd.AddCommand(&cobra.Command{
|
||||||
|
Use: verb + " RESERVATION", Short: short, Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
disabled := !on
|
||||||
|
var resp api.MutationResponse
|
||||||
|
if err := c.Do(ctx(), "PATCH", "/reservations/"+url.PathEscape(args[0]), nil, api.ReservationInput{Disabled: &disabled}, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(&resp)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var hostname, clientID, comment, ip, mac string
|
var hostname, clientID, comment, ip, mac string
|
||||||
var opts, unsetOpts []string
|
var opts, unsetOpts []string
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ 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", "comment"}`; `ip` and `mac` or `client_id` needed |
|
| `POST /reservations` | `{"ip", "mac", "client_id", "hostname", "options", "disabled", "comment"}`; `ip` and `mac` or `client_id` needed |
|
||||||
| `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
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ one. An option Steward doesn't know: `--option 224:string=hello`.
|
|||||||
| `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] [--option NAME=VALUE]... [--comment C]` | `-` for MAC with `--client-id` |
|
||||||
| `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--option]... [--unset-option]... [--comment]` | |
|
| `reservation update RES [--ip] [--mac] [--client-id] [--hostname] [--option]... [--unset-option]... [--comment]` | |
|
||||||
|
| `reservation enable\|disable RES` | use a reservation, or keep it but ignore it |
|
||||||
| `reservation delete RES` | |
|
| `reservation delete RES` | |
|
||||||
|
|
||||||
### lease
|
### lease
|
||||||
|
|||||||
+10
-1
@@ -34,7 +34,8 @@ answered from the interface's address in their own subnet, or its first
|
|||||||
address.
|
address.
|
||||||
|
|
||||||
A subnet can be **disabled**: its configuration and leases stay, but its
|
A subnet can be **disabled**: its configuration and leases stay, but its
|
||||||
clients get no answers (`steward subnet disable NAME`).
|
clients get no answers (`steward subnet disable NAME`, or Disable in the
|
||||||
|
web UI).
|
||||||
|
|
||||||
## Pools
|
## Pools
|
||||||
|
|
||||||
@@ -67,6 +68,14 @@ steward lease reserve 10.0.10.57 # the address a client has now
|
|||||||
* **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.
|
||||||
|
|
||||||
|
A reservation can be **disabled** (`steward reservation disable RES`, or
|
||||||
|
Disable in the web UI). It is kept but ignored: the client gets a pool
|
||||||
|
address like any other, and the reserved address, if it is inside a pool,
|
||||||
|
may go to someone else. The client keeps the address it has until its
|
||||||
|
lease runs out. While it is disabled, another reservation may use its
|
||||||
|
address or client; enabling it again is then refused until one of them
|
||||||
|
moves.
|
||||||
|
|
||||||
A client with a reservation always gets that address, not a pool address.
|
A client with a reservation always gets that address, not a pool address.
|
||||||
If it asks for another one, for example after the reservation was added,
|
If it asks for another one, for example after the reservation was added,
|
||||||
it gets a NAK that names its reserved address, and then the reservation.
|
it gets a NAK that names its reserved address, and then the reservation.
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ type ReservationInput struct {
|
|||||||
IP *string `json:"ip,omitempty"`
|
IP *string `json:"ip,omitempty"`
|
||||||
Hostname *string `json:"hostname,omitempty"`
|
Hostname *string `json:"hostname,omitempty"`
|
||||||
Options *[]OptionInput `json:"options,omitempty"`
|
Options *[]OptionInput `json:"options,omitempty"`
|
||||||
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ func compile(st *model.State) *config {
|
|||||||
c.byIface[s.Interface] = append(c.byIface[s.Interface], sc)
|
c.byIface[s.Interface] = append(c.byIface[s.Interface], sc)
|
||||||
}
|
}
|
||||||
for _, r := range st.Reservations {
|
for _, r := range st.Reservations {
|
||||||
|
if r.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
c.resByIP[r.IP] = r
|
c.resByIP[r.IP] = r
|
||||||
s := st.SubnetFor(r.IP)
|
s := st.SubnetFor(r.IP)
|
||||||
if s == nil {
|
if s == nil {
|
||||||
|
|||||||
@@ -444,3 +444,20 @@ func TestPrune(t *testing.T) {
|
|||||||
t.Errorf("old lease still saved: %+v", ls)
|
t.Errorf("old lease still saved: %+v", ls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDisabledReservation(t *testing.T) {
|
||||||
|
// The printer's reservation (10.0.10.101, inside the pool) is
|
||||||
|
// disabled: the printer gets a pool address like anyone, and its
|
||||||
|
// reserved address can go to others.
|
||||||
|
h := newHarness(t, func(st *model.State) { st.Reservations[1].Disabled = true })
|
||||||
|
got := map[string]bool{}
|
||||||
|
for i := byte(1); i <= 4; i++ {
|
||||||
|
got[h.lease("eth0", mac(i)).YIAddr.String()] = true
|
||||||
|
}
|
||||||
|
if !got["10.0.10.101"] {
|
||||||
|
t.Errorf("the disabled reservation's address wasn't handed out: %v", got)
|
||||||
|
}
|
||||||
|
if out := h.discover("eth0", mac(0x98)); out != nil {
|
||||||
|
t.Errorf("the printer got %s from a full pool", out.Packet.YIAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,7 +195,10 @@ type Reservation struct {
|
|||||||
// 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"`
|
||||||
Comment string `json:"comment"`
|
// 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"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Label names the reservation in messages.
|
// Label names the reservation in messages.
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func (s *Server) presentSubnet(sn *model.Subnet, st *model.State, leases []*mode
|
|||||||
}
|
}
|
||||||
reserved := map[netip.Addr]bool{}
|
reserved := map[netip.Addr]bool{}
|
||||||
for _, res := range st.Reservations {
|
for _, res := range st.Reservations {
|
||||||
if sn.Prefix.Contains(res.IP) {
|
if sn.Prefix.Contains(res.IP) && !res.Disabled {
|
||||||
out.Usage.Reservations++
|
out.Usage.Reservations++
|
||||||
reserved[res.IP] = true
|
reserved[res.IP] = true
|
||||||
for _, p := range sn.Pools {
|
for _, p := range sn.Pools {
|
||||||
@@ -421,6 +421,9 @@ func applyReservationInput(res *model.Reservation, in *api.ReservationInput) err
|
|||||||
}
|
}
|
||||||
res.Options = opts
|
res.Options = opts
|
||||||
}
|
}
|
||||||
|
if in.Disabled != nil {
|
||||||
|
res.Disabled = *in.Disabled
|
||||||
|
}
|
||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
res.Comment = *in.Comment
|
res.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func (s *Server) presentLease(l *model.Lease, st *model.State) *api.Lease {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, r := range st.Reservations {
|
for _, r := range st.Reservations {
|
||||||
if r.IP == l.IP {
|
if r.IP == l.IP && !r.Disabled {
|
||||||
out.Reservation, out.ReservedHostname = r.ID, r.Hostname
|
out.Reservation, out.ReservedHostname = r.ID, r.Hostname
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, comment FROM reservations ORDER BY id`)
|
rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, disabled, comment FROM reservations ORDER BY id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -58,10 +58,12 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
r := &model.Reservation{}
|
r := &model.Reservation{}
|
||||||
var ip, opts string
|
var ip, opts string
|
||||||
if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &r.Comment); err != nil {
|
var disabled int
|
||||||
|
if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &disabled, &r.Comment); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
r.IP, _ = netip.ParseAddr(ip)
|
r.IP, _ = netip.ParseAddr(ip)
|
||||||
|
r.Disabled = disabled != 0
|
||||||
if err := json.Unmarshal([]byte(opts), &r.Options); err != nil {
|
if err := json.Unmarshal([]byte(opts), &r.Options); err != nil {
|
||||||
return nil, fmt.Errorf("reservation %d options: %w", r.ID, err)
|
return nil, fmt.Errorf("reservation %d options: %w", r.ID, err)
|
||||||
}
|
}
|
||||||
@@ -158,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, comment) VALUES (?,?,?,?,?,?,?)
|
_, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, 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, comment = excluded.comment`,
|
options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`,
|
||||||
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), r.Comment)
|
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), 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)
|
||||||
|
|||||||
@@ -193,6 +193,10 @@ CREATE INDEX audit_time ON audit (time);
|
|||||||
ALTER TABLE tokens ADD COLUMN rotated_at TEXT;
|
ALTER TABLE tokens ADD COLUMN rotated_at TEXT;
|
||||||
ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT '';
|
ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT '';
|
||||||
ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
|
ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
|
||||||
|
`,
|
||||||
|
// 3: disabled reservations.
|
||||||
|
`
|
||||||
|
ALTER TABLE reservations ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func TestStateHistoryAndLeases(t *testing.T) {
|
|||||||
sn := &model.Subnet{ID: ids[0], Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"),
|
sn := &model.Subnet{ID: ids[0], Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"),
|
||||||
Pools: []model.Pool{{Start: netip.MustParseAddr("10.0.10.100"), End: netip.MustParseAddr("10.0.10.199")}},
|
Pools: []model.Pool{{Start: netip.MustParseAddr("10.0.10.100"), End: netip.MustParseAddr("10.0.10.199")}},
|
||||||
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}}}
|
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}}}
|
||||||
r := &model.Reservation{ID: ids[1], MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas"}
|
r := &model.Reservation{ID: ids[1], MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas", Disabled: true}
|
||||||
set := st.Settings
|
set := st.Settings
|
||||||
set.LeaseTime = 3600
|
set.LeaseTime = 3600
|
||||||
e := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "cli", Description: "add office",
|
e := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "cli", Description: "add office",
|
||||||
@@ -51,7 +51,7 @@ func TestStateHistoryAndLeases(t *testing.T) {
|
|||||||
if len(st.Subnets) != 1 || st.Subnets[0].Pools[0].Size() != 100 || st.Subnets[0].Options[0].Value != "10.0.10.1" {
|
if len(st.Subnets) != 1 || st.Subnets[0].Pools[0].Size() != 100 || st.Subnets[0].Options[0].Value != "10.0.10.1" {
|
||||||
t.Errorf("subnets %+v", st.Subnets)
|
t.Errorf("subnets %+v", st.Subnets)
|
||||||
}
|
}
|
||||||
if len(st.Reservations) != 1 || st.Reservations[0].Hostname != "nas" || st.Settings.LeaseTime != 3600 {
|
if len(st.Reservations) != 1 || st.Reservations[0].Hostname != "nas" || !st.Reservations[0].Disabled || st.Settings.LeaseTime != 3600 {
|
||||||
t.Errorf("reservations %+v settings %+v", st.Reservations, st.Settings)
|
t.Errorf("reservations %+v settings %+v", st.Reservations, st.Settings)
|
||||||
}
|
}
|
||||||
// Undo: the inverse changes, marked as a revert.
|
// Undo: the inverse changes, marked as a revert.
|
||||||
|
|||||||
@@ -275,18 +275,22 @@ func (c *checker) reservations(st *model.State, env *Env) {
|
|||||||
if r.IP == s.Prefix.Addr() || r.IP == model.Last(s.Prefix) {
|
if r.IP == s.Prefix.Addr() || r.IP == model.Last(s.Prefix) {
|
||||||
c.add(Error, "reservation_network_address", obj, "%s is the network or broadcast address of %s", r.IP, s.Prefix)
|
c.add(Error, "reservation_network_address", obj, "%s is the network or broadcast address of %s", r.IP, s.Prefix)
|
||||||
}
|
}
|
||||||
if o := byIP[r.IP]; o != nil {
|
// A disabled reservation is ignored, so it takes no address or
|
||||||
c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label())
|
// client from another; enabling it again is checked.
|
||||||
|
if !r.Disabled {
|
||||||
|
if o := byIP[r.IP]; o != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
byIP[r.IP] = r
|
|
||||||
k := key{s.ID, "mac " + r.MAC}
|
|
||||||
if r.ClientID != "" {
|
|
||||||
k.id = "id " + r.ClientID
|
|
||||||
}
|
|
||||||
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.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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,22 @@ func TestProblems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDisabledReservationsDontConflict(t *testing.T) {
|
||||||
|
st := good()
|
||||||
|
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
|
||||||
|
st.Reservations[0].Disabled = true
|
||||||
|
// The same client and address again, enabled: fine while the first is
|
||||||
|
// disabled.
|
||||||
|
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20")})
|
||||||
|
if ps := Check(st, nil); len(ps) != 0 {
|
||||||
|
t.Errorf("problems: %v", ps)
|
||||||
|
}
|
||||||
|
st.Reservations[0].Disabled = false
|
||||||
|
if got := codes(Check(st, nil)); !strings.Contains(got, "reservation_ip_taken") {
|
||||||
|
t.Errorf("enabling it again: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSameMACInTwoSubnets(t *testing.T) {
|
func TestSameMACInTwoSubnets(t *testing.T) {
|
||||||
st := good()
|
st := good()
|
||||||
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
|
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Reservations: fixed addresses for clients.
|
// Reservations: fixed addresses for clients.
|
||||||
import { h, mount, table, input, modal, errorBox, field, confirmDialog, select, debounce, badge } from "../ui.js";
|
import { h, mount, table, 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 } from "../widgets.js";
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import { optionsEditor, subnetLabel } from "../widgets.js";
|
|||||||
export function reservationsTable(rs, done, opts = {}) {
|
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) },
|
{ 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.client_id ? "id " + r.client_id : r.mac) },
|
||||||
{ 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) : "–" },
|
||||||
@@ -18,9 +18,18 @@ export function reservationsTable(rs, done, opts = {}) {
|
|||||||
{ label: "Comment", render: (r) => r.comment },
|
{ label: "Comment", render: (r) => r.comment },
|
||||||
write ? { label: "", tdClass: "actions-cell", render: (r) => [
|
write ? { label: "", tdClass: "actions-cell", render: (r) => [
|
||||||
h("button", { class: "small reveal", onclick: () => reservationDialog(r, done) }, "Edit"), " ",
|
h("button", { class: "small reveal", onclick: () => reservationDialog(r, done) }, "Edit"), " ",
|
||||||
|
h("button", { class: "small reveal", title: r.disabled ? "Use this reservation again" : "Keep it, but ignore it: the client gets a pool address like any other",
|
||||||
|
onclick: () => toggle(r, done) }, r.disabled ? "Enable" : "Disable"), " ",
|
||||||
h("button", { class: "small reveal", onclick: () => del(r, done) }, "Delete"),
|
h("button", { class: "small reveal", onclick: () => del(r, done) }, "Delete"),
|
||||||
] } : null,
|
] } : null,
|
||||||
].filter(Boolean), rs, { empty: opts.empty || "No reservations." });
|
].filter(Boolean), rs, { empty: opts.empty || "No reservations.", rowClass: (r) => r.disabled ? "disabled" : "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(r, done) {
|
||||||
|
try {
|
||||||
|
await change("PATCH", "/reservations/" + r.id, { disabled: !r.disabled });
|
||||||
|
done();
|
||||||
|
} catch (e) { modal(r.disabled ? "Not enabled" : "Not disabled", errorBox(e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(r, done) {
|
async function del(r, done) {
|
||||||
@@ -44,6 +53,7 @@ export async function reservationDialog(r, done, preset = {}) {
|
|||||||
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 : "" });
|
||||||
|
const disabled = checkbox("Disabled: keep it, but ignore it (the client gets a pool address like any other)", r ? r.disabled : false);
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
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."),
|
||||||
@@ -51,12 +61,14 @@ export async function reservationDialog(r, done, preset = {}) {
|
|||||||
field("Client identifier", cid, "Optional: match the client's identifier (option 61, hex) instead of its MAC address."),
|
field("Client identifier", cid, "Optional: match the client's identifier (option 61, hex) instead of its MAC address."),
|
||||||
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)), err);
|
field("Comment", comment),
|
||||||
|
r ? [h("label"), disabled] : null), err);
|
||||||
await modal(r ? "Edit reservation " + r.ip : "New reservation", body, [
|
await modal(r ? "Edit reservation " + r.ip : "New reservation", body, [
|
||||||
{ 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 v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), comment: comment.value };
|
||||||
|
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);
|
||||||
done();
|
done();
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ export async function render(el, params) {
|
|||||||
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
|
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
|
||||||
s.pools.length ? h("span", { class: "mono" }, s.pools.map((p) => p.start + "-" + p.end).join(", ")) : "none",
|
s.pools.length ? h("span", { class: "mono" }, s.pools.map((p) => p.start + "-" + p.end).join(", ")) : "none",
|
||||||
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
|
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
|
||||||
h("div", { class: "subnet-usage" }, usageBar(s.usage))),
|
h("div", { class: "subnet-usage" }, usageBar(s.usage)),
|
||||||
|
can("config", true) ? h("div", { class: "actions" }, h("button", {
|
||||||
|
title: s.disabled ? "Answer this subnet's clients again" : "Stop answering this subnet's clients; its configuration and leases stay",
|
||||||
|
onclick: () => toggleSubnet(s) }, s.disabled ? "Enable subnet" : "Disable subnet")) : null),
|
||||||
|
s.disabled ? h("div", { class: "msg warn" }, "This subnet is disabled: its clients get no answers. Its configuration and leases are kept.") : null,
|
||||||
h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/subnets/" + id + "/" + k, class: tab === k ? "active" : "" }, label))),
|
h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/subnets/" + id + "/" + k, class: tab === k ? "active" : "" }, label))),
|
||||||
body);
|
body);
|
||||||
if (tab === "leases") await leasesTab(body, s);
|
if (tab === "leases") await leasesTab(body, s);
|
||||||
@@ -29,6 +33,14 @@ export async function render(el, params) {
|
|||||||
else await settingsTab(body, s);
|
else await settingsTab(body, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleSubnet(s) {
|
||||||
|
if (!s.disabled && !await confirmDialog("Disable subnet", "Stop answering the clients of " + subnetLabel(s) + "? They keep their addresses until their leases run out, then have none. The configuration and leases stay; Enable brings it back.", "Disable", "danger")) return;
|
||||||
|
try {
|
||||||
|
await change("PATCH", "/subnets/" + s.id, { disabled: !s.disabled });
|
||||||
|
rerender();
|
||||||
|
} catch (e) { modal(s.disabled ? "Not enabled" : "Not disabled", errorBox(e)); }
|
||||||
|
}
|
||||||
|
|
||||||
async function leasesTab(el, s) {
|
async function leasesTab(el, s) {
|
||||||
const search = input({ type: "search", placeholder: "address, MAC or host name" });
|
const search = input({ type: "search", placeholder: "address, MAC or host name" });
|
||||||
const out = h("div");
|
const out = h("div");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { h, mount, table, modal, errorBox, badge } from "../ui.js";
|
import { h, mount, table, modal, errorBox, badge } from "../ui.js";
|
||||||
import { get, change, can } from "../api.js";
|
import { get, change, can } from "../api.js";
|
||||||
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
|
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
|
||||||
|
import { toggleSubnet } from "./subnet.js";
|
||||||
|
|
||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
const subnets = await get("/subnets");
|
const subnets = await get("/subnets");
|
||||||
@@ -18,8 +19,9 @@ export async function render(el, params, query) {
|
|||||||
{ label: "In use", render: (s) => usageBar(s.usage) },
|
{ label: "In use", render: (s) => usageBar(s.usage) },
|
||||||
{ label: "Reserved", class: "num", tdClass: "num", render: (s) => String(s.usage.reservations) },
|
{ label: "Reserved", class: "num", tdClass: "num", render: (s) => String(s.usage.reservations) },
|
||||||
{ label: "Lease time", render: (s) => s.lease_time ? fmtSeconds(s.lease_time) : h("span", { class: "muted" }, "default") },
|
{ label: "Lease time", render: (s) => s.lease_time ? fmtSeconds(s.lease_time) : h("span", { class: "muted" }, "default") },
|
||||||
{ label: "", render: (s) => s.disabled ? badge("disabled", "off") : null },
|
{ label: "", tdClass: "actions-cell", render: (s) => [s.disabled ? [badge("disabled", "off"), " "] : null,
|
||||||
], subnets, { empty: "No subnets yet. Add one for each network Steward should serve." })));
|
can("config", true) ? h("button", { class: "small reveal", onclick: () => toggleSubnet(s) }, s.disabled ? "Enable" : "Disable") : null] },
|
||||||
|
], subnets, { empty: "No subnets yet. Add one for each network Steward should serve.", rowClass: (s) => s.disabled ? "disabled" : "" })));
|
||||||
if (query.get("new") === "1" && can("config", true)) newSubnet();
|
if (query.get("new") === "1" && can("config", true)) newSubnet();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,11 +110,9 @@ header.top .spacer { flex: 1; }
|
|||||||
header.top form.search input { width: 220px; }
|
header.top form.search input { width: 220px; }
|
||||||
header.top .user { color: var(--muted); }
|
header.top .user { color: var(--muted); }
|
||||||
main { padding: 16px; max-width: 1400px; margin: 0 auto; }
|
main { padding: 16px; max-width: 1400px; margin: 0 auto; }
|
||||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||||
background: var(--warn-soft); color: var(--text); border-bottom: 1px solid var(--border);
|
.page-head > .actions { padding: 4px 0; }
|
||||||
padding: 8px 16px;
|
.filter-row + .panel, .actions + .panel { margin-top: 4px; }
|
||||||
}
|
|
||||||
.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
||||||
.page-head .grow { flex: 1; min-width: 200px; }
|
.page-head .grow { flex: 1; min-width: 200px; }
|
||||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ run "client on eth1 gets the other subnet" 0 udhcpc b 02:00:00:00:00:02 -x hostn
|
|||||||
contains "BOUND ip=$NB.100 mask=255.255.255.0 router=$NB.1 dns= domain=lan lease=3600 serverid=$NB.2"
|
contains "BOUND ip=$NB.100 mask=255.255.255.0 router=$NB.1 dns= domain=lan lease=3600 serverid=$NB.2"
|
||||||
run "reserved client gets its address, host name and option" 0 udhcpc a 02:00:00:00:00:20
|
run "reserved client gets its address, host name and option" 0 udhcpc a 02:00:00:00:00:20
|
||||||
contains "BOUND ip=$NA.20 .*hostname=printer ntp=$NA.2"
|
contains "BOUND ip=$NA.20 .*hostname=printer ntp=$NA.2"
|
||||||
|
run "disable the reservation" 0 S reservation disable $NA.20
|
||||||
|
run "a disabled reservation's client gets a pool address" 0 udhcpc a 02:00:00:00:00:20
|
||||||
|
contains "BOUND ip=$NA.10"
|
||||||
|
not_contains "hostname=printer"
|
||||||
|
POOLIP=$(echo "$OUT" | sed -n 's/^BOUND ip=\([0-9.]*\) .*/\1/p')
|
||||||
|
run "enable it again" 0 S reservation enable $NA.20
|
||||||
|
run "the reserved address again" 0 udhcpc a 02:00:00:00:00:20
|
||||||
|
contains "BOUND ip=$NA.20 .*hostname=printer"
|
||||||
|
run "free the pool address it had meanwhile" 0 S lease delete "$POOLIP" --yes
|
||||||
run "the same device on the other VLAN gets that VLAN's address" 0 udhcpc b 02:00:00:00:00:20
|
run "the same device on the other VLAN gets that VLAN's address" 0 udhcpc b 02:00:00:00:00:20
|
||||||
contains "BOUND ip=$NB.101 "
|
contains "BOUND ip=$NB.101 "
|
||||||
run "leases" 0 S lease list
|
run "leases" 0 S lease list
|
||||||
|
|||||||
Reference in new issue
Block a user