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>
478 lines
15 KiB
Go
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 = ¬
|
|
}
|
|
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
|
|
}
|