- 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>
502 lines
13 KiB
Go
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, "; ")
|
|
}
|