Files
steward/cmd/steward/main.go
T
abrown38andClaude Opus 5.5 5e6c61cc8e Hidden interfaces, last-seen pings, activity by subnet
- interfaces.exclude in steward.yaml (names or shell patterns) leaves
  interfaces out of GET /interfaces, the web UI's picker and
  'steward interface list'. Subnets on them are still served.
- With the ping check on, active leases are pinged every minute; a
  client that answers gets a last-seen time (leases.last_seen,
  migration 4). Not answering never takes a client offline.
- The activity log filters by subnet (--subnet, the Activity page,
  GET /events?subnet=).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:50:21 +01:00

502 lines
13 KiB
Go

// Command steward is the command line client for Steward. It talks to
// stewardd's REST API: on the server itself through the local socket, or
// anywhere with the server's URL and an API token.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"go.yaml.in/yaml/v3"
"steward/internal/api"
"steward/internal/client"
"steward/internal/model"
"steward/internal/validate"
"steward/internal/version"
)
func main() {
if err := rootCmd().Execute(); err != nil {
printError(err)
os.Exit(exitCode(err))
}
}
// Global flags.
var (
flagServer string
flagToken string
flagConfig string
flagDryRun bool
flagOutput string
flagInsecure bool
flagCACert string
flagQuiet bool
)
// DefaultSocket is stewardd's local socket.
const DefaultSocket = "/run/steward/steward.sock"
// cliConfig is the CLI's configuration file.
type cliConfig struct {
Server string `yaml:"server,omitempty"`
Token string `yaml:"token,omitempty"`
Insecure bool `yaml:"insecure,omitempty"`
CACert string `yaml:"ca_cert,omitempty"`
}
func defaultConfigPath() string {
if p := os.Getenv("STEWARD_CLI_CONFIG"); p != "" {
return p
}
dir, err := os.UserConfigDir()
if err != nil {
dir = filepath.Join(os.Getenv("HOME"), ".config")
}
return filepath.Join(dir, "steward", "config.yaml")
}
func loadCLIConfig() (*cliConfig, error) {
c := &cliConfig{}
b, err := os.ReadFile(flagConfig)
if errors.Is(err, os.ErrNotExist) {
return c, nil
}
if err != nil {
return nil, err
}
return c, yaml.Unmarshal(b, c)
}
func saveCLIConfig(c *cliConfig) error {
if err := os.MkdirAll(filepath.Dir(flagConfig), 0o700); err != nil {
return err
}
b, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(flagConfig, b, 0o600)
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// newClient connects to the server: --server, STEWARD_SERVER, the config
// file, or else the local socket.
func newClient() (*client.Client, error) {
cfg, err := loadCLIConfig()
if err != nil {
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
}
server := firstNonEmpty(flagServer, os.Getenv("STEWARD_SERVER"), cfg.Server)
token := firstNonEmpty(flagToken, os.Getenv("STEWARD_TOKEN"), cfg.Token)
if server == "" || server == "local" {
sock := firstNonEmpty(os.Getenv("STEWARD_SOCKET"), DefaultSocket)
if _, err := os.Stat(sock); err != nil {
return nil, fmt.Errorf("no server configured, and there is no local socket %s: use --server URL, STEWARD_SERVER or 'steward config set server https://...'; on the server itself, check that stewardd runs", sock)
}
server = "unix://" + sock
}
if strings.HasPrefix(token, "@") {
b, err := os.ReadFile(token[1:])
if err != nil {
return nil, fmt.Errorf("reading the token file: %w", err)
}
token = strings.TrimSpace(string(b))
}
if _, isSocket := client.SocketPath(server); !isSocket && token == "" {
return nil, errors.New("no API token: use --token, STEWARD_TOKEN or 'steward config set token'")
}
c, err := client.New(client.Options{Server: server, Token: token, Insecure: flagInsecure || cfg.Insecure, CACert: firstNonEmpty(flagCACert, cfg.CACert)})
if err != nil {
return nil, err
}
c.DryRun = flagDryRun
return c, nil
}
func rootCmd() *cobra.Command {
root := &cobra.Command{
Use: "steward",
Short: "Command line client for Steward, the DHCP server",
Long: `steward manages a Steward DHCP server through its API.
Every change is checked as a whole before it is saved and used, and is
recorded in the history, where 'steward history undo ID' takes it back.
--dry-run shows what a change would do.
On the server, root needs no setup: the CLI uses stewardd's local socket.
From elsewhere, set the server and an API token once:
steward config set server https://dhcp.example.com:8067
steward config set token (paste the token at the prompt)
Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
5 conflict, 6 invalid or rejected by validation, 7 server error.`,
SilenceUsage: true,
SilenceErrors: true,
Version: version.String(),
}
pf := root.PersistentFlags()
pf.StringVarP(&flagServer, "server", "s", "", "server URL, or \"local\" for the local socket (env STEWARD_SERVER)")
pf.StringVar(&flagToken, "token", "", "API token, or @FILE to read it from a file (env STEWARD_TOKEN)")
pf.StringVar(&flagConfig, "config", defaultConfigPath(), "CLI configuration file")
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "check and show a change without making it")
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml or id")
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)")
root.AddGroup(&cobra.Group{ID: "dhcp", Title: "DHCP:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
add := func(group string, cmds ...*cobra.Command) {
for _, c := range cmds {
c.GroupID = group
root.AddCommand(c)
}
}
add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd())
add("changes", historyCmd(), importCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
return root
}
func ctx() context.Context { return context.Background() }
// ---- output
func output(v any, table func(w io.Writer)) error {
switch flagOutput {
case "json":
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
case "yaml":
b, err := json.Marshal(v)
if err != nil {
return err
}
var x any
if err := json.Unmarshal(b, &x); err != nil {
return err
}
return yaml.NewEncoder(os.Stdout).Encode(x)
case "id":
return outputIDs(v)
case "table", "":
if table == nil {
flagOutput = "yaml"
return output(v, nil)
}
tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
table(tw)
return tw.Flush()
}
return fmt.Errorf("unknown output format %q (use table, json, yaml or id)", flagOutput)
}
// outputIDs prints the id (or for leases the ip) of each object.
func outputIDs(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
var x any
if err := dec.Decode(&x); err != nil {
return err
}
list, ok := x.([]any)
if !ok {
list = []any{x}
}
for _, it := range list {
if m, ok := it.(map[string]any); ok {
if id, ok := m["id"]; ok {
fmt.Println(id)
} else if ip, ok := m["ip"]; ok {
fmt.Println(ip)
}
}
}
return nil
}
func printProblems(w io.Writer, ps []validate.Problem) {
for _, p := range ps {
obj := ""
if p.Object != "" {
obj = p.Object + ": "
}
fmt.Fprintf(w, "%s: %s%s [%s]\n", p.Severity, obj, p.Message, p.Code)
}
}
func printError(err error) {
var ce *client.Error
if errors.As(err, &ce) {
ps := ce.Info.Problems
if len(ps) == 1 {
fmt.Fprintf(os.Stderr, "error: %s [%s]\n", ce.Info.Message, ps[0].Code)
return
}
fmt.Fprintln(os.Stderr, "error:", ce.Info.Message)
if len(ps) > 1 {
printProblems(os.Stderr, ps)
}
return
}
fmt.Fprintln(os.Stderr, "error:", err)
}
// exitCode maps errors to exit codes: 1 other errors, 3 not authenticated
// or not allowed, 4 not found, 5 conflict, 6 invalid request or rejected
// by validation, 7 server error.
func exitCode(err error) int {
var ce *client.Error
if errors.As(err, &ce) {
switch {
case ce.Status == 401 || ce.Status == 403:
return 3
case ce.Status == 404:
return 4
case ce.Status == 409:
return 5
case ce.Status == 400 || ce.Status == 422:
return 6
case ce.Status >= 500:
return 7
}
}
return 1
}
// report prints the outcome of a change.
func report(resp *api.MutationResponse) error {
switch flagOutput {
case "json", "yaml":
return output(resp, nil)
case "id":
if len(resp.Object) > 0 {
return outputIDs(resp.Object)
}
return nil
}
w := os.Stdout
verb := func(c model.Change) string {
if resp.DryRun {
return "would " + c.Action()
}
return c.Action() + "d"
}
if !flagQuiet || resp.DryRun {
for _, c := range resp.Changes {
if c.Kind == model.KindSettings {
fmt.Fprintf(w, "%s settings\n", verb(c))
continue
}
fmt.Fprintf(w, "%s %s %s\n", verb(c), c.Kind, c.Label)
}
}
switch {
case resp.DryRun:
fmt.Fprintln(w, "dry run: nothing was changed")
case resp.Applied && !flagQuiet:
fmt.Fprintf(w, "saved (history %d; undo with 'steward history undo %d')\n", resp.HistoryID, resp.HistoryID)
case !resp.Applied && len(resp.Changes) == 0 && !flagQuiet:
fmt.Fprintln(w, "nothing to change")
}
if flagQuiet && len(resp.Object) > 0 && resp.Applied {
outputIDs(resp.Object)
}
printProblems(os.Stderr, resp.Warnings)
return nil
}
func q(kv ...string) url.Values {
v := url.Values{}
for i := 0; i+1 < len(kv); i += 2 {
if kv[i+1] != "" {
v.Set(kv[i], kv[i+1])
}
}
return v
}
func confirm(prompt string, yes bool) bool {
if yes || flagDryRun {
return true
}
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
var answer string
fmt.Scanln(&answer)
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes")
}
// ---- durations
// parseSeconds reads a lease time: "12h", "30m", "7d", "1d12h" or plain
// seconds.
func parseSeconds(s string) (int, error) {
s = strings.TrimSpace(s)
if n, err := strconv.Atoi(s); err == nil {
return n, nil
}
days := 0
if i := strings.Index(s, "d"); i > 0 {
n, err := strconv.Atoi(s[:i])
if err != nil {
return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s)
}
days, s = n, s[i+1:]
}
var d time.Duration
if s != "" {
var err error
if d, err = time.ParseDuration(s); err != nil {
return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s)
}
}
return days*86400 + int(d/time.Second), nil
}
// fmtSeconds writes seconds the short way: 43200 is "12h", 90 is "1m30s".
func fmtSeconds(n int) string {
if n <= 0 {
return "-"
}
var parts []string
for _, u := range []struct {
s int
name string
}{{86400, "d"}, {3600, "h"}, {60, "m"}, {1, "s"}} {
if n >= u.s {
parts = append(parts, strconv.Itoa(n/u.s)+u.name)
n %= u.s
}
}
return strings.Join(parts, "")
}
// fmtWhen writes a time relative to now: "in 3h", "2d ago".
func fmtWhen(t time.Time) string {
if t.IsZero() {
return "-"
}
d := time.Until(t)
ago := d < 0
if ago {
d = -d
}
var s string
switch {
case d < time.Minute:
return "now"
case d < time.Hour:
s = fmt.Sprintf("%dm", int(d.Minutes()))
case d < 48*time.Hour:
s = fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
default:
s = fmt.Sprintf("%dd", int(d.Hours()/24))
}
if ago {
return s + " ago"
}
return "in " + s
}
// fmtSeen is a last-seen time, or "-" for a client never seen.
func fmtSeen(t *time.Time) string {
if t == nil {
return "-"
}
return fmtWhen(*t)
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
// optionsArg turns --option NAME=VALUE flags into option inputs.
func optionsArg(flags []string) ([]api.OptionInput, error) {
var out []api.OptionInput
for _, f := range flags {
name, value, ok := strings.Cut(f, "=")
if !ok {
return nil, fmt.Errorf("--option %q: write it as NAME=VALUE, e.g. routers=10.0.10.1", f)
}
in := api.OptionInput{Option: strings.TrimSpace(name), Value: strings.TrimSpace(value)}
// A custom option's type: 224:string=hello.
if n, typ, ok := strings.Cut(in.Option, ":"); ok {
in.Option, in.Type = n, typ
}
out = append(out, in)
}
return out, nil
}
// mergeOptions sets and unsets options on a current list (by code).
func mergeOptions(cur []model.OptionValue, set []api.OptionInput, unset []string) ([]api.OptionInput, error) {
out := []api.OptionInput{}
drop := map[string]bool{}
for _, u := range unset {
drop[strings.ToLower(strings.TrimSpace(u))] = true
}
setNames := map[string]bool{}
for _, s := range set {
setNames[strings.ToLower(s.Option)] = true
}
for _, o := range cur {
code := strconv.Itoa(o.Code)
if drop[o.Name] || drop[code] || setNames[o.Name] || setNames[code] {
continue
}
out = append(out, api.OptionInput{Option: code, Value: o.Value, Type: o.Type})
}
for name := range drop {
found := false
for _, o := range cur {
if o.Name == name || strconv.Itoa(o.Code) == name {
found = true
}
}
if !found {
return nil, fmt.Errorf("option %s isn't set here", name)
}
}
return append(out, set...), nil
}
func optionsText(opts []model.OptionValue) string {
var parts []string
for _, o := range opts {
parts = append(parts, o.Name+"="+o.Value)
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, "; ")
}