Files
steward/cmd/steward/subnets.go
T
abrown38andClaude Opus 5.5 3ad2f981d4 Allow and deny lists per subnet; every option of a DHCP message in words
- A subnet is a deny-list subnet (the default: everyone not denied) or
  an allow-list subnet (only clients allowed or with a reservation);
  the deny list counts in both. Entries are MAC addresses, prefixes
  (aa:bb:cc:*) or client IDs. A refused client gets no answer (DENIED
  in the activity log, with an Allow button) or a NAK on renewal.
  steward access, the subnet's Access tab. Schema migration 7.
- The activity log keeps each message's header fields and options,
  decoded into words (dhcp4.Describe): the parameter request list by
  name, client IDs, lease times. ▸ on an Activity row, steward
  activity show N / --details, GET /events/{id}.

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

619 lines
19 KiB
Go

package main
import (
"fmt"
"io"
"net/url"
"strings"
"github.com/spf13/cobra"
"steward/internal/api"
"steward/internal/model"
)
// subnetPath is the API path of a subnet given by ID, name or prefix.
func subnetPath(ref string) string {
return "/subnets/" + url.PathEscape(strings.Replace(ref, "/", "_", 1))
}
// orderText says how new addresses are chosen: "pools listed, addresses
// the settings' order".
func orderText(s *model.Subnet) string {
pools := "pools in the order listed"
if s.PoolOrder == model.PoolsRandom {
pools = "a pool at random"
}
addrs := "the settings' order"
if s.AddressOrder != "" {
addrs = s.AddressOrder
}
return pools + ", addresses " + addrs + " (unless a pool has its own)"
}
func poolsText(ps []model.Pool) string {
var out []string
for _, p := range ps {
out = append(out, p.String())
}
if len(out) == 0 {
return "-"
}
return strings.Join(out, ", ")
}
func usageText(u api.Usage) string {
if u.PoolSize == 0 {
return "-"
}
return fmt.Sprintf("%d/%d (%d%%)", u.Leased, u.PoolSize, u.Leased*100/u.PoolSize)
}
func subnetCmd() *cobra.Command {
cmd := &cobra.Command{Use: "subnet", Aliases: []string{"subnets"}, Short: "Subnets: the networks Steward serves, each on its interface"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the subnets with their interface, pools and use", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var subs []api.Subnet
if err := c.Get(ctx(), "/subnets", nil, &subs); err != nil {
return err
}
return output(subs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tNAME\tINTERFACE\tPREFIX\tPOOLS\tLEASED\tRESERVED\tLEASE TIME\tSTATE")
for _, s := range subs {
state := "on"
if s.Disabled {
state = "disabled"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\n", s.ID, orDash(s.Name), s.Interface, s.Prefix, poolsText(s.Pools), usageText(s.Usage),
s.Usage.Reservations, fmtSeconds(s.LeaseTime), state)
}
})
},
})
cmd.AddCommand(&cobra.Command{
Use: "show SUBNET", Short: "Show a subnet (by ID, name or prefix)", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var s api.Subnet
if err := c.Get(ctx(), subnetPath(args[0]), nil, &s); err != nil {
return err
}
return output(s, func(w io.Writer) {
fmt.Fprintf(w, "ID:\t%d\n", s.ID)
fmt.Fprintf(w, "Name:\t%s\n", orDash(s.Name))
fmt.Fprintf(w, "Interface:\t%s\n", s.Interface)
fmt.Fprintf(w, "Prefix:\t%s (mask %s)\n", s.Prefix, model.Mask(s.Prefix))
fmt.Fprintf(w, "Pools:\t%s\n", poolsText(s.Pools))
fmt.Fprintf(w, "Order:\t%s\n", orderText(s.Subnet))
fmt.Fprintf(w, "Access:\t%s\n", accessText(s.Subnet))
fmt.Fprintf(w, "Leased:\t%s, %d declined\n", usageText(s.Usage), s.Usage.Declined)
fmt.Fprintf(w, "Reservations:\t%d (%d with a lease now)\n", s.Usage.Reservations, s.Usage.ReservationsLeased)
lt := "the default"
if s.LeaseTime > 0 {
lt = fmtSeconds(s.LeaseTime)
}
fmt.Fprintf(w, "Lease time:\t%s\n", lt)
fmt.Fprintln(w, "Options:\t"+optionsText(s.Options))
if s.Disabled {
fmt.Fprintln(w, "State:\tdisabled (no answers)")
}
if s.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", s.Comment)
}
})
},
})
var name, iface, leaseTime, comment, prefix, poolOrder, addrOrder string
var pools, opts, addPools, removePools, unsetOpts []string
add := &cobra.Command{
Use: "add PREFIX --interface IFACE",
Short: "Add a subnet",
Long: `Adds a subnet, served on one interface: only clients on that interface
get its addresses.
steward subnet add 10.0.10.0/24 --interface eth0 --name office \
--pool 10.0.10.100-10.0.10.199 \
--option routers=10.0.10.1 --option domain-name-servers=10.0.10.2
Pools are FIRST-LAST or a prefix; give --pool once per pool. A new client
gets a never-used address: from the pools in the order given (or a pool at
random: --pool-order random), the lowest first (or --address-order reverse
or random). A pool can have its own order: --pool 10.0.10.200-10.0.10.250:random.
Options are
NAME=VALUE ('steward option list' has the names); a number works for any
other option, with its type for ones Steward doesn't know: 224:string=hi.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.SubnetInput{Prefix: &args[0], Interface: &iface, Pools: &pools}
if pools == nil {
in.Pools = &[]string{}
}
if name != "" {
in.Name = &name
}
if comment != "" {
in.Comment = &comment
}
if leaseTime != "" {
n, err := parseSeconds(leaseTime)
if err != nil {
return err
}
in.LeaseTime = &n
}
if poolOrder != "" {
in.PoolOrder = &poolOrder
}
if addrOrder != "" {
in.AddressOrder = &addrOrder
}
if len(opts) > 0 {
o, err := optionsArg(opts)
if err != nil {
return err
}
in.Options = &o
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/subnets", nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f := add.Flags()
f.StringVar(&iface, "interface", "", "the network interface it is served on (required)")
f.StringVar(&name, "name", "", "a name for it")
f.StringArrayVar(&pools, "pool", nil, "a pool: FIRST-LAST or a prefix, with :ORDER for its own address order (repeat for several)")
f.StringVar(&poolOrder, "pool-order", "", "which pool a new address comes from: listed (in the order given, the default) or random")
f.StringVar(&addrOrder, "address-order", "", "which address of a pool: sequential, reverse or random (default: the settings')")
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d; default: the settings')")
f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE (repeat for several)")
f.StringVar(&comment, "comment", "", "a comment")
add.MarkFlagRequired("interface")
cmd.AddCommand(add)
update := &cobra.Command{
Use: "update SUBNET",
Short: "Change a subnet",
Long: `Changes a subnet. Only what is given changes. --pool replaces all pools,
in the order given; --add-pool adds one at the end, or changes the order
of a pool it already has; --remove-pool removes one. --option sets an
option (keeping the others), --unset-option removes one.
steward subnet update office --add-pool 10.0.10.200-10.0.10.220
steward subnet update office --address-order reverse --add-pool 10.0.10.200-10.0.10.220:random
steward subnet update office --option ntp-servers=10.0.10.2 --unset-option time-offset`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cur api.Subnet
if err := c.Get(ctx(), subnetPath(args[0]), nil, &cur); err != nil {
return err
}
in := api.SubnetInput{}
fl := cmd.Flags()
if fl.Changed("name") {
in.Name = &name
}
if fl.Changed("interface") {
in.Interface = &iface
}
if fl.Changed("prefix") {
in.Prefix = &prefix
}
if fl.Changed("comment") {
in.Comment = &comment
}
if fl.Changed("pool-order") {
in.PoolOrder = &poolOrder
}
if fl.Changed("address-order") {
in.AddressOrder = &addrOrder
}
if fl.Changed("lease-time") {
n := 0
if leaseTime != "default" {
if n, err = parseSeconds(leaseTime); err != nil {
return err
}
}
in.LeaseTime = &n
}
if fl.Changed("pool") || len(addPools) > 0 || len(removePools) > 0 {
list := []string{}
if !fl.Changed("pool") {
for _, p := range cur.Pools {
list = append(list, p.String())
}
} else {
list = append(list, pools...)
}
for _, r := range removePools {
rp, err := model.ParsePool(r)
if err != nil {
return err
}
found := false
for i, p := range list {
if pp, _ := model.ParsePool(p); pp.SameRange(rp) {
list = append(list[:i], list[i+1:]...)
found = true
break
}
}
if !found {
return fmt.Errorf("the subnet has no pool %s (it has %s)", rp, poolsText(cur.Pools))
}
}
// A pool it has already changes in place (its order).
adding:
for _, a := range addPools {
ap, err := model.ParsePool(a)
if err != nil {
return err
}
for i, p := range list {
if pp, _ := model.ParsePool(p); pp.SameRange(ap) {
list[i] = a
continue adding
}
}
list = append(list, a)
}
in.Pools = &list
}
if len(opts) > 0 || len(unsetOpts) > 0 {
set, err := optionsArg(opts)
if err != nil {
return err
}
merged, err := mergeOptions(cur.Options, set, unsetOpts)
if err != nil {
return err
}
in.Options = &merged
}
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", subnetPath(args[0]), nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f = update.Flags()
f.StringVar(&name, "name", "", "a new name (\"\" for none)")
f.StringVar(&iface, "interface", "", "a new interface")
f.StringVar(&prefix, "prefix", "", "a new prefix")
f.StringArrayVar(&pools, "pool", nil, "replace the pools (repeat for several)")
f.StringArrayVar(&addPools, "add-pool", nil, "add a pool (FIRST-LAST[:ORDER]), or set the order of one it has")
f.StringVar(&poolOrder, "pool-order", "", "which pool a new address comes from: listed or random")
f.StringVar(&addrOrder, "address-order", "", "which address of a pool: sequential, reverse, random or default (the settings')")
f.StringArrayVar(&removePools, "remove-pool", nil, "remove a pool")
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d, or \"default\")")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option (by name or number)")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update)
for _, on := range []bool{true, false} {
on := on
verb := "disable"
short := "Stop answering a subnet's clients (its configuration and leases stay)"
if on {
verb, short = "enable", "Answer a disabled subnet's clients again"
}
cmd.AddCommand(&cobra.Command{
Use: verb + " SUBNET", 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", subnetPath(args[0]), nil, api.SubnetInput{Disabled: &disabled}, &resp); err != nil {
return err
}
return report(&resp)
},
})
}
var yes bool
del := &cobra.Command{
Use: "delete SUBNET", Short: "Delete a subnet and its reservations", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cur api.Subnet
if err := c.Get(ctx(), subnetPath(args[0]), nil, &cur); err != nil {
return err
}
msg := fmt.Sprintf("Delete subnet %s", cur.Label())
if cur.Usage.Reservations > 0 {
msg += fmt.Sprintf(" and its %d reservations", cur.Usage.Reservations)
}
if !confirm(msg+"?", yes) {
return fmt.Errorf("not deleted")
}
var resp api.MutationResponse
if err := c.Do(ctx(), "DELETE", subnetPath(args[0]), nil, nil, &resp); err != nil {
return err
}
return report(&resp)
},
}
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del)
return cmd
}
func reservationCmd() *cobra.Command {
cmd := &cobra.Command{Use: "reservation", Aliases: []string{"reservations", "res"}, Short: "Reservations: fixed addresses for clients"}
var subnet string
list := &cobra.Command{
Use: "list", Short: "List reservations", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var rs []api.Reservation
if err := c.Get(ctx(), "/reservations", q("subnet", subnet), &rs); err != nil {
return err
}
return output(rs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tIP\tMAC / CLIENT ID\tHOSTNAME\tSUBNET\tLEASE\tSTATE\tCOMMENT")
for _, r := range rs {
who := r.MAC
if r.ClientID != "" {
who = strings.TrimPrefix(r.MAC+" or id "+r.ClientID, " or ")
}
lease := "-"
if r.Lease != nil {
lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04")
if r.Lease.LastSeen != nil {
lease += ", seen " + fmtWhen(*r.Lease.LastSeen)
}
}
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)
}
})
},
}
list.Flags().StringVar(&subnet, "subnet", "", "only this subnet's (ID, name or prefix)")
cmd.AddCommand(list)
cmd.AddCommand(&cobra.Command{
Use: "show RESERVATION", Short: "Show a reservation (by ID, address or MAC address)", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r api.Reservation
if err := c.Get(ctx(), "/reservations/"+url.PathEscape(args[0]), nil, &r); err != nil {
return err
}
return output(r, func(w io.Writer) {
fmt.Fprintf(w, "ID:\t%d\n", r.ID)
fmt.Fprintf(w, "Address:\t%s (subnet %s)\n", r.IP, orDash(r.SubnetName))
if r.MAC != "" {
fmt.Fprintf(w, "MAC address:\t%s\n", r.MAC)
}
if r.ClientID != "" {
fmt.Fprintf(w, "Client ID:\t%s\n", r.ClientID)
}
fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname))
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 {
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 {
fmt.Fprintf(w, "Last seen:\t%s (answered a ping)\n", fmtWhen(*r.Lease.LastSeen))
}
} else {
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 != "" {
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, leaseTime string
var opts, unsetOpts []string
add := &cobra.Command{
Use: "add IP MAC",
Short: "Reserve an address for a client",
Long: `Reserves an address for a client, known by its MAC address, its client
identifier (option 61: give "-" for the MAC and --client-id), or both:
then either one gets it the address, as for a machine that boots two
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 --lease-time 7d`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.ReservationInput{IP: &args[0]}
if args[1] != "-" {
in.MAC = &args[1]
}
if clientID != "" {
in.ClientID = &clientID
}
if hostname != "" {
in.Hostname = &hostname
}
if comment != "" {
in.Comment = &comment
}
if leaseTime != "" {
n, err := parseSeconds(leaseTime)
if err != nil {
return err
}
in.LeaseTime = &n
}
if len(opts) > 0 {
o, err := optionsArg(opts)
if err != nil {
return err
}
in.Options = &o
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/reservations", nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f := add.Flags()
f.StringVar(&hostname, "hostname", "", "the host name to send it")
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.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
update := &cobra.Command{
Use: "update RESERVATION", Short: "Change a reservation (only what is given changes)", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path := "/reservations/" + url.PathEscape(args[0])
var cur api.Reservation
if err := c.Get(ctx(), path, nil, &cur); err != nil {
return err
}
in := api.ReservationInput{}
fl := cmd.Flags()
if fl.Changed("ip") {
in.IP = &ip
}
if fl.Changed("mac") {
in.MAC = &mac
}
if fl.Changed("client-id") {
in.ClientID = &clientID
}
if fl.Changed("hostname") {
in.Hostname = &hostname
}
if fl.Changed("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 {
set, err := optionsArg(opts)
if err != nil {
return err
}
merged, err := mergeOptions(cur.Options, set, unsetOpts)
if err != nil {
return err
}
in.Options = &merged
}
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", path, nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f = update.Flags()
f.StringVar(&ip, "ip", "", "a new address")
f.StringVar(&mac, "mac", "", "a new MAC address (\"\" for none)")
f.StringVar(&clientID, "client-id", "", "a client identifier (\"\" for none)")
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(&unsetOpts, "unset-option", nil, "remove an option")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update)
cmd.AddCommand(&cobra.Command{
Use: "delete RESERVATION", Short: "Delete a reservation", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "DELETE", "/reservations/"+url.PathEscape(args[0]), nil, nil, &resp); err != nil {
return err
}
return report(&resp)
},
})
return cmd
}