Files
steward/cmd/steward/leases.go
T
abrown38andClaude Opus 5.5 1bc9f9f63a Let the Reserve button pick another address of the lease's subnet
The Reserve dialog fills in the lease's address but lets it be changed;
POST /leases/{ip}/reserve takes an optional ip, steward lease reserve
takes --ip. The address must be in the lease's subnet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:29:07 +01:00

478 lines
15 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, ip 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.
--ip reserves another address of the same subnet instead; the client
moves there when it next renews.`,
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
}
if ip != "" {
body["ip"] = ip
}
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(&ip, "ip", "", "reserve this address of the lease's subnet instead")
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, details 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
--details (-d) shows every option of each message in words: what the
client asked for (its parameter request list), its client ID, and what
Steward sent back. 'steward activity show N' shows one message (N is the
number at the start of its line).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
detailsQ := ""
if details {
detailsQ = "1"
}
var last uint64
tw := func(evs []api.Event) {
sort.Slice(evs, func(i, j int) bool { return evs[i].ID < evs[j].ID })
for _, e := range evs {
if e.ID <= last {
continue
}
last = e.ID
detail := ""
if e.Hostname != "" {
detail = " " + e.Hostname
}
if e.Detail != "" {
detail += " (" + e.Detail + ")"
}
fmt.Printf("%-6s %s %-8s %-13s %-17s %-15s%s\n", fmt.Sprintf("#%d", e.ID), e.Time.Local().Format("15:04:05.000"), e.Interface, e.Type, orDash(e.MAC), orDash(e.IP), detail)
if details {
printEventDetails(os.Stdout, e, " ")
fmt.Println()
}
}
}
for {
var evs []api.Event
if err := c.Get(ctx(), "/events", q("mac", mac, "subnet", subnet, "interface", iface, "limit", fmt.Sprint(limit), "details", detailsQ), &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")
f.BoolVarP(&details, "details", "d", false, "show each message's fields and options")
cmd.AddCommand(&cobra.Command{
Use: "show N", Short: "Show one DHCP message with all its options", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var e api.Event
if err := c.Get(ctx(), "/events/"+strings.TrimPrefix(args[0], "#"), nil, &e); err != nil {
return err
}
return output(e, func(w io.Writer) {
dir := map[string]string{"in": "received from the client", "out": "sent by Steward"}[e.Direction]
fmt.Fprintf(w, "#%d %s on %s at %s", e.ID, e.Type, e.Interface, e.Time.Local().Format("2006-01-02 15:04:05.000"))
if dir != "" {
fmt.Fprintf(w, ", %s", dir)
}
fmt.Fprintln(w)
if e.Detail != "" {
fmt.Fprintf(w, "%s\n", e.Detail)
}
printEventDetails(w, e, " ")
})
},
})
return cmd
}
// printEventDetails writes a message's fields and options, one per line.
func printEventDetails(w io.Writer, e api.Event, indent string) {
for _, f := range e.Fields {
fmt.Fprintf(w, "%s%s: %s\n", indent, f.Name, f.Value)
}
for _, o := range e.Options {
fmt.Fprintf(w, "%soption %d %s: %s\n", indent, o.Code, o.Name, o.Value)
}
}
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
}