Files
steward/cmd/steward/leases.go
T
abrown38andClaude Opus 5.5 4fd72998ec Reservation lease time; reservations by MAC address or client ID
- A reservation can have its own lease time (--lease-time, the
  reservation dialog), over the subnet's. Schema migration 6.
- A reservation with a MAC address and a client ID matches either one
  (a dual-boot machine sends two client IDs from one MAC); before, it
  was found by MAC and then refused. A lease of the reserved address by
  the client's other identity counts as its own.
- The Leases page shows a client's client ID; reserving a lease uses
  both, or one with --by / the dialog's choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:26:24 +01:00

422 lines
14 KiB
Go

package main
import (
"fmt"
"io"
"os"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"steward/internal/api"
"steward/internal/model"
)
func leaseHost(l api.Lease) string {
switch {
case l.ReservedHostname != "" && l.Hostname != "" && l.ReservedHostname != l.Hostname:
return l.ReservedHostname + " (says " + l.Hostname + ")"
case l.ReservedHostname != "":
return l.ReservedHostname
}
return orDash(l.Hostname)
}
func leaseCmd() *cobra.Command {
cmd := &cobra.Command{Use: "lease", Aliases: []string{"leases"}, Short: "Leases: the addresses handed out"}
var subnet, state, iface, search string
var all bool
list := &cobra.Command{
Use: "list",
Short: "List leases (active and declined ones; --all for every one)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var ls []api.Lease
if err := c.Get(ctx(), "/leases", q("subnet", subnet, "state", state, "interface", iface, "q", search), &ls); err != nil {
return err
}
if !all && state == "" {
keep := ls[:0]
for _, l := range ls {
if l.State == model.LeaseActive || l.State == model.LeaseDeclined {
keep = append(keep, l)
}
}
ls = keep
}
return output(ls, func(w io.Writer) {
fmt.Fprintln(w, "IP\tMAC\tHOSTNAME\tSTATE\tENDS\tLAST SEEN\tINTERFACE\tSUBNET")
for _, l := range ls {
st := l.State
if l.Reservation != 0 {
st += ", reserved"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", l.IP, orDash(l.MAC), leaseHost(l), st, fmtWhen(l.End), fmtSeen(l.LastSeen), l.Interface, orDash(l.SubnetName))
}
})
},
}
f := list.Flags()
f.StringVar(&subnet, "subnet", "", "only this subnet's (ID, name or prefix)")
f.StringVar(&state, "state", "", "only leases in this state: active, expired, released or declined")
f.StringVar(&iface, "interface", "", "only leases of clients on this interface")
f.StringVar(&search, "search", "", "only leases whose address, MAC address or host name contains this")
f.BoolVarP(&all, "all", "a", false, "also expired and released leases (kept a while so returning clients get their old address)")
cmd.AddCommand(list)
cmd.AddCommand(&cobra.Command{
Use: "show IP", Short: "Show the lease of an address", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var l api.Lease
if err := c.Get(ctx(), "/leases/"+args[0], nil, &l); err != nil {
return err
}
return output(l, func(w io.Writer) {
fmt.Fprintf(w, "Address:\t%s (subnet %s)\n", l.IP, orDash(l.SubnetName))
fmt.Fprintf(w, "State:\t%s\n", l.State)
fmt.Fprintf(w, "MAC address:\t%s\n", orDash(l.MAC))
if l.ClientID != "" {
fmt.Fprintf(w, "Client ID:\t%s\n", l.ClientID)
}
fmt.Fprintf(w, "Host name:\t%s\n", leaseHost(l))
fmt.Fprintf(w, "Since:\t%s\n", l.Start.Local().Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, "Ends:\t%s (%s)\n", l.End.Local().Format("2006-01-02 15:04:05"), fmtWhen(l.End))
fmt.Fprintf(w, "Interface:\t%s\n", l.Interface)
if l.LastSeen != nil {
fmt.Fprintf(w, "Last seen:\t%s (%s, answered a ping)\n", l.LastSeen.Local().Format("2006-01-02 15:04:05"), fmtWhen(*l.LastSeen))
}
if l.Reservation != 0 {
fmt.Fprintf(w, "Reservation:\t%d\n", l.Reservation)
}
})
},
})
var yes bool
del := &cobra.Command{
Use: "delete IP",
Short: "Forget a lease, freeing the address",
Long: `Forgets a lease: the address is free at once. A client still using it
gets a NAK when it next renews, and then asks for a new address.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm("Delete the lease of "+args[0]+"?", yes) {
return fmt.Errorf("not deleted")
}
c, err := newClient()
if err != nil {
return err
}
if err := c.Do(ctx(), "DELETE", "/leases/"+args[0], nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("deleted the lease of", args[0])
}
return nil
},
}
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del)
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.
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()
if err != nil {
return err
}
body := map[string]any{}
if cmd.Flags().Changed("hostname") {
body["hostname"] = hostname
}
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
}
return report(&resp)
},
}
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
}
func activityCmd() *cobra.Command {
var mac, subnet, iface string
var limit int
var follow bool
cmd := &cobra.Command{
Use: "activity",
Short: "Show recent DHCP messages (requests and answers)",
Long: `Shows the latest DHCP messages Steward received and sent, newest last.
-f keeps watching. Handy to see why a device doesn't get an address:
steward activity --mac aa:bb:cc:dd:ee:01 -f`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var last time.Time
tw := func(evs []api.Event) {
sort.Slice(evs, func(i, j int) bool { return evs[i].Time.Before(evs[j].Time) })
for _, e := range evs {
if !e.Time.After(last) {
continue
}
last = e.Time
detail := ""
if e.Hostname != "" {
detail = " " + e.Hostname
}
if e.Detail != "" {
detail += " (" + e.Detail + ")"
}
fmt.Printf("%s %-8s %-13s %-17s %-15s%s\n", e.Time.Local().Format("15:04:05.000"), e.Interface, e.Type, orDash(e.MAC), orDash(e.IP), detail)
}
}
for {
var evs []api.Event
if err := c.Get(ctx(), "/events", q("mac", mac, "subnet", subnet, "interface", iface, "limit", fmt.Sprint(limit)), &evs); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(evs, nil)
}
tw(evs)
if !follow {
if len(evs) == 0 {
fmt.Fprintln(os.Stderr, "no DHCP messages yet")
}
return nil
}
time.Sleep(2 * time.Second)
}
},
}
f := cmd.Flags()
f.StringVar(&mac, "mac", "", "only this client's")
f.StringVar(&subnet, "subnet", "", "only this subnet's (ID, name or prefix); messages without an address, such as DISCOVERs, show for every subnet on their interface")
f.StringVar(&iface, "interface", "", "only this interface's")
f.IntVar(&limit, "limit", 50, "at most this many")
f.BoolVarP(&follow, "follow", "f", false, "keep watching")
return cmd
}
func settingsCmd() *cobra.Command {
cmd := &cobra.Command{Use: "settings", Short: "Server-wide settings and the options every subnet gets"}
cmd.AddCommand(&cobra.Command{
Use: "show", Short: "Show the settings", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var s model.Settings
if err := c.Get(ctx(), "/settings", nil, &s); err != nil {
return err
}
return output(s, func(w io.Writer) {
fmt.Fprintf(w, "Lease time:\t%s\n", fmtSeconds(s.EffectiveLeaseTime()))
fmt.Fprintln(w, "Options:\t"+optionsText(s.Options))
ping := "off"
if s.PingCheck {
ping = "on, waiting " + s.EffectivePingTimeout().String()
}
fmt.Fprintf(w, "Ping check:\t%s\n", ping)
fmt.Fprintf(w, "Decline hold:\t%s\n", fmtSeconds(int(s.EffectiveDeclineHold()/time.Second)))
auth := "yes (requests for addresses that don't belong on the interface get a NAK)"
if s.NotAuthoritative {
auth = "no (such requests get no answer)"
}
fmt.Fprintf(w, "Authoritative:\t%s\n", auth)
fmt.Fprintf(w, "Address order:\t%s (subnets and pools can set their own)\n", s.EffectiveAddressOrder())
})
},
})
var leaseTime, ping, pingTimeout, hold, authoritative, addrOrder string
var opts, unsetOpts []string
set := &cobra.Command{
Use: "set",
Short: "Change settings (only what is given changes)",
Long: `Changes the settings. Options set here go to every subnet; a subnet's or a
reservation's own option of the same name wins.
steward settings set --option domain-name-servers=10.0.10.2 --option domain-name=lan
steward settings set --lease-time 1d --ping-check off`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.SettingsInput{}
fl := cmd.Flags()
if fl.Changed("lease-time") {
n, err := parseSeconds(leaseTime)
if err != nil {
return err
}
in.LeaseTime = &n
}
onOff := func(name, v string) (*bool, error) {
switch strings.ToLower(v) {
case "on", "yes", "true":
t := true
return &t, nil
case "off", "no", "false":
f := false
return &f, nil
}
return nil, fmt.Errorf("--%s: use on or off", name)
}
if fl.Changed("ping-check") {
if in.PingCheck, err = onOff("ping-check", ping); err != nil {
return err
}
}
if fl.Changed("ping-timeout") {
d, err := time.ParseDuration(pingTimeout)
if err != nil {
return fmt.Errorf("--ping-timeout: %v (use e.g. 500ms)", err)
}
ms := int(d / time.Millisecond)
in.PingTimeout = &ms
}
if fl.Changed("decline-hold") {
n, err := parseSeconds(hold)
if err != nil {
return err
}
in.DeclineHold = &n
}
if fl.Changed("authoritative") {
a, err := onOff("authoritative", authoritative)
if err != nil {
return err
}
not := !*a
in.NotAuthoritative = &not
}
if fl.Changed("address-order") {
in.AddressOrder = &addrOrder
}
if len(opts) > 0 || len(unsetOpts) > 0 {
var cur model.Settings
if err := c.Get(ctx(), "/settings", nil, &cur); err != nil {
return err
}
s, err := optionsArg(opts)
if err != nil {
return err
}
merged, err := mergeOptions(cur.Options, s, unsetOpts)
if err != nil {
return err
}
in.Options = &merged
}
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", "/settings", nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f := set.Flags()
f.StringVar(&leaseTime, "lease-time", "", "the default lease time (12h, 1d)")
f.StringVar(&ping, "ping-check", "", "ping addresses before offering them, and clients every minute for their last-seen time: on or off")
f.StringVar(&pingTimeout, "ping-timeout", "", "how long to wait for a ping's answer (500ms)")
f.StringVar(&hold, "decline-hold", "", "how long an address in use by someone else is held back (24h)")
f.StringVar(&authoritative, "authoritative", "", "refuse requests for addresses that don't belong on the interface: on or off")
f.StringVar(&addrOrder, "address-order", "", "which never-used pool address a new client gets: sequential (the lowest), reverse (the highest) or random")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE for every subnet")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
cmd.AddCommand(set)
return cmd
}
func optionCmd() *cobra.Command {
cmd := &cobra.Command{Use: "option", Aliases: []string{"options"}, Short: "The DHCP options Steward knows by name"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the options and their value types", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cat api.Catalog
if err := c.Get(ctx(), "/catalog", nil, &cat); err != nil {
return err
}
return output(cat, func(w io.Writer) {
fmt.Fprintln(w, "CODE\tNAME\tTYPE\tWHAT")
for _, o := range cat.Options {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", o.Code, o.Name, o.Type, o.Help)
}
fmt.Fprintf(w, "\nAny other option by number: --option 224=01:02 (hex), or with a type: --option 224:string=hello.\nTypes: %s.\n", strings.Join(cat.Types, ", "))
})
},
})
return cmd
}
func interfaceCmd() *cobra.Command {
cmd := &cobra.Command{Use: "interface", Aliases: []string{"interfaces"}, Short: "The server's network interfaces"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the interfaces, their addresses and subnets", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var ifs []api.HostInterface
if err := c.Get(ctx(), "/interfaces", nil, &ifs); err != nil {
return err
}
return output(ifs, func(w io.Writer) {
fmt.Fprintln(w, "INTERFACE\tADDRESSES\tSUBNETS")
for _, i := range ifs {
var subs []string
for _, s := range i.Subnets {
subs = append(subs, fmt.Sprint(s))
}
addrs := strings.Join(i.Addresses, ", ")
if len(i.Addresses) == 0 {
addrs = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\n", i.Name, addrs, orDash(strings.Join(subs, ", ")))
}
})
},
})
return cmd
}