// 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, "; ") }