package main import ( "encoding/json" "fmt" "io" "os" "sort" "strconv" "strings" "github.com/spf13/cobra" "golang.org/x/term" "housecarl/internal/api" "housecarl/internal/model" ) func historyCmd() *cobra.Command { cmd := &cobra.Command{Use: "history", Short: "The history of configuration changes, and undo"} var limit int list := &cobra.Command{ Use: "list", Short: "List recent changes", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var hs []model.HistoryEntry if err := c.Get(ctx(), "/history", q("limit", strconv.Itoa(limit)), &hs); err != nil { return err } return output(hs, func(w io.Writer) { fmt.Fprintln(w, "ID\tWHEN\tBY\tVIA\tCHANGES\tDESCRIPTION") for _, h := range hs { desc := h.Description if h.RevertedBy != 0 { desc += fmt.Sprintf(" (undone by %d)", h.RevertedBy) } fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04"), h.Username, h.Via, len(h.Changes), desc) } }) }, } list.Flags().IntVar(&limit, "limit", 30, "how many") cmd.AddCommand(list) cmd.AddCommand(&cobra.Command{ Use: "show ID", Short: "Show a change: each object before and after", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var h model.HistoryEntry if err := c.Get(ctx(), "/history/"+args[0], nil, &h); err != nil { return err } return output(h, func(w io.Writer) { fmt.Fprintf(w, "%d\t%s by %s (%s): %s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04:05"), h.Username, h.Via, h.Description) for _, ch := range h.Changes { fmt.Fprintf(w, "\n%s %s %s\n", ch.Action(), ch.Kind, ch.Label) for _, line := range diffJSON(ch.Before, ch.After) { fmt.Fprintln(w, " "+line) } } }) }, }) cmd.AddCommand(&cobra.Command{ Use: "undo ID", Short: "Undo a change", Long: `Undoes a change by applying it backwards. It is refused when an object the change touched has changed since; undo the later changes first.`, 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(), "POST", "/history/"+args[0]+"/revert", nil, nil, &resp); err != nil { return err } return report(&resp) }, }) return cmd } // diffJSON lists the fields that differ between two versions of an // object: "name: office -> lab". func diffJSON(before, after json.RawMessage) []string { var a, b map[string]any json.Unmarshal(before, &a) json.Unmarshal(after, &b) keys := map[string]bool{} for k := range a { keys[k] = true } for k := range b { keys[k] = true } var names []string for k := range keys { names = append(names, k) } sort.Strings(names) var out []string show := func(v any, ok bool) string { if !ok { return "-" } x, _ := json.Marshal(v) return string(x) } for _, k := range names { va, oka := a[k] vb, okb := b[k] sa, sb := show(va, oka), show(vb, okb) if sa == sb { continue } switch { case before == nil: out = append(out, k+": "+sb) case after == nil: out = append(out, k+": "+sa) default: out = append(out, k+": "+sa+" -> "+sb) } } return out } func whoamiCmd() *cobra.Command { return &cobra.Command{ Use: "whoami", Short: "Show who you are to the server", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var me api.Whoami if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil { return err } return output(me, func(w io.Writer) { fmt.Fprintf(w, "User:\t%s (%s)\n", me.User.Username, me.Method) fmt.Fprintf(w, "Administrator:\t%v\n", me.Admin) fmt.Fprintf(w, "May:\t%s\n", strings.Join(me.Permissions, ", ")) fmt.Fprintf(w, "Server:\tHousecarl %s\n", me.Build.String()) }) }, } } func readSecret(prompt string) (string, error) { fmt.Fprint(os.Stderr, prompt) if term.IsTerminal(int(os.Stdin.Fd())) { b, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(os.Stderr) return strings.TrimSpace(string(b)), err } var s string _, err := fmt.Fscanln(os.Stdin, &s) return strings.TrimSpace(s), err } func userID(name string) (int64, error) { c, err := newClient() if err != nil { return 0, err } var us []api.User if err := c.Get(ctx(), "/users", nil, &us); err != nil { return 0, err } for _, u := range us { if strings.EqualFold(u.Username, name) || strconv.FormatInt(u.ID, 10) == name { return u.ID, nil } } return 0, fmt.Errorf("no user %s", name) } func userCmd() *cobra.Command { cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Users (administrators only)"} cmd.AddCommand(&cobra.Command{ Use: "list", Short: "List users", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var us []api.User if err := c.Get(ctx(), "/users", nil, &us); err != nil { return err } return output(us, func(w io.Writer) { fmt.Fprintln(w, "ID\tUSER\tADMIN\tGRANTS\tPASSWORD\tDISABLED\tLAST LOGIN") for _, u := range us { login := "-" if u.LastLoginAt != nil { login = fmtWhen(*u.LastLoginAt) } fmt.Fprintf(w, "%d\t%s\t%v\t%s\t%v\t%v\t%s\n", u.ID, u.Username, u.Admin, orDash(strings.Join(u.GrantStrings, " ")), u.HasPassword, u.Disabled, login) } }) }, }) var admin, noPassword bool var role, comment string var grants []string add := &cobra.Command{ Use: "add USER", Short: "Add a user (asks for the password)", Long: `Adds a user. Without --admin, give what they may do: a role (viewer, operator, log) or grants such as --grant config:read --grant sets:write:guests. --no-password makes a service account that only uses API tokens.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } in := api.UserInput{Username: &args[0], Admin: &admin} if !noPassword { pw, err := readSecret("Password for " + args[0] + ": ") if err != nil { return err } in.Password = &pw } if role != "" { in.Role = &role } if grants != nil { in.Grants = &grants } if comment != "" { in.Comment = &comment } var u api.User if err := c.Do(ctx(), "POST", "/users", nil, in, &u); err != nil { return err } if !flagQuiet { fmt.Printf("added user %s (%d)\n", u.Username, u.ID) } return nil }, } f := add.Flags() f.BoolVar(&admin, "admin", false, "an administrator (may do everything)") f.StringVar(&role, "role", "", "a role: viewer, operator or log") f.StringArrayVar(&grants, "grant", nil, "a grant, resource:access (config, sets, log, audit; read or write; sets:write:SET,SET)") f.BoolVar(&noPassword, "no-password", false, "no password: API tokens only") f.StringVar(&comment, "comment", "", "a comment") cmd.AddCommand(add) var setPassword, disable, enable bool update := &cobra.Command{ Use: "update USER", Short: "Change a user", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { id, err := userID(args[0]) if err != nil { return err } c, _ := newClient() in := api.UserInput{} fl := cmd.Flags() if fl.Changed("admin") { in.Admin = &admin } if fl.Changed("role") { in.Role = &role } if fl.Changed("grant") { in.Grants = &grants } if fl.Changed("comment") { in.Comment = &comment } if disable || enable { in.Disabled = &disable } if setPassword { pw, err := readSecret("New password for " + args[0] + ": ") if err != nil { return err } in.Password = &pw } var u api.User if err := c.Do(ctx(), "PATCH", "/users/"+strconv.FormatInt(id, 10), nil, in, &u); err != nil { return err } if !flagQuiet { fmt.Printf("updated user %s\n", u.Username) } return nil }, } f = update.Flags() f.BoolVar(&admin, "admin", false, "make (or with --admin=false, unmake) an administrator") f.StringVar(&role, "role", "", "replace the grants with a role's") f.StringArrayVar(&grants, "grant", nil, "replace the grants (repeat for several)") f.BoolVar(&setPassword, "password", false, "set a new password (asks for it)") f.BoolVar(&disable, "disable", false, "disable the user (logs them out)") f.BoolVar(&enable, "enable", false, "enable the user again") f.StringVar(&comment, "comment", "", "a comment") cmd.AddCommand(update) cmd.AddCommand(&cobra.Command{ Use: "delete USER", Short: "Delete a user and their tokens", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { id, err := userID(args[0]) if err != nil { return err } c, _ := newClient() if err := c.Do(ctx(), "DELETE", "/users/"+strconv.FormatInt(id, 10), nil, nil, nil); err != nil { return err } if !flagQuiet { fmt.Println("deleted user", args[0]) } return nil }, }) return cmd } func tokenCmd() *cobra.Command { cmd := &cobra.Command{Use: "token", Aliases: []string{"tokens"}, Short: "API tokens"} var all bool var user string list := &cobra.Command{ Use: "list", Short: "List your tokens (--all: everyone's, for administrators)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } path := "/me/tokens" if all { path = "/tokens" } var ts []api.Token if err := c.Get(ctx(), path, nil, &ts); err != nil { return err } return output(ts, func(w io.Writer) { fmt.Fprintln(w, "ID\tUSER\tNAME\tGRANTS\tEXPIRES\tLAST USED") for _, t := range ts { exp, used := "never", "never" if t.ExpiresAt != nil { exp = fmtWhen(*t.ExpiresAt) } if t.LastUsedAt != nil { used = fmtWhen(*t.LastUsedAt) + " from " + t.LastUsedIP } fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, strings.Join(t.GrantStrings, " "), exp, used) } }) }, } list.Flags().BoolVar(&all, "all", false, "everyone's tokens") cmd.AddCommand(list) var role, expires string var grants []string var save bool create := &cobra.Command{ Use: "create NAME", Short: "Make an API token (the secret is shown once)", Long: `Makes an API token. It has all of its user's permissions unless --role or --grant narrows it. On the server as root, --user names whose token it is. --save puts it in the CLI's configuration.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } path := "/me/tokens" if user != "" { id, err := userID(user) if err != nil { return err } path = "/users/" + strconv.FormatInt(id, 10) + "/tokens" } in := api.TokenInput{Name: args[0], Role: role, Grants: grants, ExpiresIn: expires} var t api.Token if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil { return err } if save { cfg, err := loadCLIConfig() if err != nil { return err } cfg.Token = t.Secret if err := saveCLIConfig(cfg); err != nil { return err } fmt.Fprintln(os.Stderr, "saved in", flagConfig) } if flagOutput == "json" || flagOutput == "yaml" { return output(t, nil) } fmt.Println(t.Secret) if !flagQuiet { fmt.Fprintln(os.Stderr, "this is the only time the token is shown; keep it safe") } return nil }, } f := create.Flags() f.StringVar(&role, "role", "", "narrow it to a role: viewer, operator or log") f.StringArrayVar(&grants, "grant", nil, "narrow it to grants (resource:access)") f.StringVar(&expires, "expires", "", "expire after this long (90d, 720h)") f.StringVar(&user, "user", "", "the user it belongs to (administrators)") f.BoolVar(&save, "save", false, "save it as this CLI's token") cmd.AddCommand(create) var grace, rexp string var current, rsave bool rotate := &cobra.Command{ Use: "rotate [ID]", Short: "Give a token a new secret (shown once)", Long: `Gives a token a new secret. It keeps its ID, name and permissions, so only the programs that use it need the new secret. The old secret stops at once, or after --grace (up to 7d) so you can update them without an outage. A token that expires gets its lifetime again, from now, unless --expires says otherwise (90d, or never). hc token rotate --current --save # this CLI's own token`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } id := "" switch { case current && len(args) == 0: var me api.Whoami if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil { return err } if me.TokenID == 0 { return fmt.Errorf("this CLI isn't using a token (%s); give the token's ID", me.Method) } id = strconv.FormatInt(me.TokenID, 10) case len(args) == 1 && !current: id = args[0] default: return fmt.Errorf("give the token's ID, or --current") } body := api.TokenRotation{Grace: grace} switch rexp { case "": case "never": body.NoExpiry = true default: body.ExpiresIn = rexp } var t api.Token if err := c.Do(ctx(), "POST", "/tokens/"+id+"/rotate", nil, body, &t); err != nil { return err } if rsave { cfg, err := loadCLIConfig() if err != nil { return err } cfg.Token = t.Secret if err := saveCLIConfig(cfg); err != nil { return err } fmt.Fprintln(os.Stderr, "saved in", flagConfig) } if flagOutput == "json" || flagOutput == "yaml" { return output(t, nil) } fmt.Println(t.Secret) if !flagQuiet { if t.PrevExpiresAt != nil { fmt.Fprintf(os.Stderr, "the old secret works until %s\n", t.PrevExpiresAt.Local().Format("2006-01-02 15:04")) } else { fmt.Fprintln(os.Stderr, "the old secret no longer works") } } return nil }, } f = rotate.Flags() f.StringVar(&grace, "grace", "", "keep the old secret working this long (30m, 24h, up to 7d)") f.StringVar(&rexp, "expires", "", "a new lifetime from now (90d), or never") f.BoolVar(¤t, "current", false, "rotate the token this CLI uses") f.BoolVar(&rsave, "save", false, "save the new secret as this CLI's token") cmd.AddCommand(rotate) cmd.AddCommand(&cobra.Command{ Use: "revoke ID", Short: "Revoke a token", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } if err := c.Do(ctx(), "DELETE", "/tokens/"+args[0], nil, nil, nil); err != nil { return err } if !flagQuiet { fmt.Println("revoked token", args[0]) } return nil }, }) return cmd } func passwordCmd() *cobra.Command { return &cobra.Command{ Use: "password", Short: "Change your own password", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } cur, err := readSecret("Current password: ") if err != nil { return err } pw, err := readSecret("New password: ") if err != nil { return err } if err := c.Do(ctx(), "POST", "/me/password", nil, api.PasswordChange{Current: cur, Password: pw}, nil); err != nil { return err } fmt.Println("password changed") return nil }, } } func auditCmd() *cobra.Command { var user, action, search string var limit int cmd := &cobra.Command{ Use: "audit", Short: "Show the audit log: logins, users, tokens, changes, confirmations, set entries", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var evs []model.AuditEvent if err := c.Get(ctx(), "/audit", q("user", user, "action", action, "q", search, "limit", strconv.Itoa(limit)), &evs); err != nil { return err } return output(evs, func(w io.Writer) { fmt.Fprintln(w, "WHEN\tUSER\tFROM\tACTION\tOBJECT\tRESULT\tDETAIL") for _, e := range evs { fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Time.Local().Format("2006-01-02 15:04:05"), orDash(e.Username), orDash(e.IP), e.Action, orDash(e.Object), e.Result, e.Detail) } }) }, } f := cmd.Flags() f.StringVar(&user, "user", "", "only this user's") f.StringVar(&action, "action", "", "only actions starting with this (auth., config., set.)") f.StringVar(&search, "search", "", "only events whose object or detail contains this") f.IntVar(&limit, "limit", 100, "how many") return cmd } func configCmd() *cobra.Command { cmd := &cobra.Command{Use: "config", Short: "This CLI's settings: the server and token"} cmd.AddCommand(&cobra.Command{ Use: "show", Short: "Show the CLI's configuration", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadCLIConfig() if err != nil { return err } tok := "-" if cfg.Token != "" { tok = cfg.Token[:min(len(cfg.Token), 24)] + "…" } fmt.Printf("file: %s\nserver: %s\ntoken: %s\ninsecure: %v\nca_cert: %s\n", flagConfig, orDash(cfg.Server)+" (empty: the local socket)", tok, cfg.Insecure, orDash(cfg.CACert)) return nil }, }) cmd.AddCommand(&cobra.Command{ Use: "set KEY [VALUE]", Short: "Set server, token (asks when no value), insecure or ca_cert", Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadCLIConfig() if err != nil { return err } val := "" if len(args) == 2 { val = args[1] } else if args[0] == "token" { if val, err = readSecret("API token: "); err != nil { return err } } switch args[0] { case "server": cfg.Server = val case "token": cfg.Token = val case "insecure": cfg.Insecure = val == "true" || val == "yes" || val == "on" case "ca_cert", "ca-cert": cfg.CACert = val default: return fmt.Errorf("unknown setting %q (use server, token, insecure or ca_cert)", args[0]) } if err := saveCLIConfig(cfg); err != nil { return err } fmt.Fprintln(os.Stderr, "saved in", flagConfig) return nil }, }) return cmd }